From 719799f71182eb2bf89ef2c800d0f45f3c9061e6 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Wed, 7 Oct 2026 14:51:37 -0400 Subject: [PATCH 1/4] feat(sync): add scriptable prompt commands --- cmd/sync/prompt.go | 354 ++++++++++++++++++---- cmd/sync/prompt_test.go | 100 +++++- cmd/sync/sync.go | 7 +- internal/sync/api/attachment.go | 12 +- internal/sync/api/catalog.go | 20 +- internal/sync/api/catalog_test.go | 2 +- internal/sync/api/client.go | 75 ++++- internal/sync/api/client_test.go | 100 +++--- internal/sync/bootstrap/bootstrap.go | 81 ++++- internal/sync/bootstrap/bootstrap_test.go | 35 ++- internal/sync/detach/detach.go | 82 +++-- internal/sync/detach/detach_test.go | 35 ++- internal/sync/fingerprint.go | 2 - internal/sync/fingerprint_test.go | 10 +- internal/sync/link/link.go | 99 +++++- internal/sync/link/link_test.go | 85 ++++++ internal/sync/prompt/acceptance_test.go | 101 +++++- internal/sync/prompt/attach.go | 6 +- internal/sync/prompt/conflict.go | 112 +++++-- internal/sync/prompt/conflict_test.go | 63 +++- internal/sync/prompt/runner.go | 219 ++++++++----- internal/sync/prompt/runner_test.go | 78 ++--- internal/sync/prompt/terminal.go | 2 +- internal/sync/prompt/terminal_test.go | 6 +- internal/sync/prompt/watch.go | 3 + internal/sync/prompt/watch_test.go | 25 ++ internal/sync/resource.go | 22 ++ 27 files changed, 1373 insertions(+), 363 deletions(-) diff --git a/cmd/sync/prompt.go b/cmd/sync/prompt.go index b5aa3b1f..dd376061 100644 --- a/cmd/sync/prompt.go +++ b/cmd/sync/prompt.go @@ -3,6 +3,7 @@ package sync import ( "fmt" "os" + "strings" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -13,23 +14,21 @@ import ( "github.com/launchdarkly/ldcli/internal/output" "github.com/launchdarkly/ldcli/internal/resources" syncdomain "github.com/launchdarkly/ldcli/internal/sync" + synclink "github.com/launchdarkly/ldcli/internal/sync/link" syncprompt "github.com/launchdarkly/ldcli/internal/sync/prompt" ) const ( - addFlag = "add" - attachSkillFlag = "attach-skill" - attachToolFlag = "attach-tool" - detachFlag = "detach" - dryRunFlag = "dry-run" - formatFlag = "format" - linkFlag = "link" - projectFlag = "project" - variationFlag = "variation" - watchFlag = "watch" - yesFlag = "yes" - - interactiveAttachment = "__interactive__" + conflictFlag = "conflict" + contentFlag = "content" + dryRunFlag = "dry-run" + formatFlag = "format" + modelConfigKeyFlag = "model-config-key" + nameFlag = "name" + noInputFlag = "no-input" + resolveFlag = "resolve" + toFlag = "to" + yesFlag = "yes" ) // NewPromptCmd creates the prompt synchronization command. @@ -39,51 +38,60 @@ func NewPromptCmd(client resources.Client) *cobra.Command { 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 + + # Add the first variation without prompting + ldcli sync prompt add production/support/default --no-input + + # Apply changes in an existing workspace without prompting + ldcli sync prompt --yes --no-input + + # Prefer local changes when conflicts occur + ldcli sync prompt --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) }, - RunE: runPrompt(client), - } - - cmd.Flags().Bool(addFlag, false, "Select additional prompt variations from LaunchDarkly") - cmd.Flags().String(attachSkillFlag, "", "Attach the latest skill version by key, or select interactively") - cmd.Flags().Lookup(attachSkillFlag).NoOptDefVal = interactiveAttachment - cmd.Flags().String(attachToolFlag, "", "Attach the latest tool version by key, or select interactively") - cmd.Flags().Lookup(attachToolFlag).NoOptDefVal = interactiveAttachment - cmd.Flags().Bool(detachFlag, false, "Select local resources to stop syncing") - cmd.Flags().Bool(dryRunFlag, false, "Preview synchronization changes without applying them") - cmd.Flags().String(linkFlag, "", "Link an external prompt file") - cmd.Flags().String(formatFlag, "", "Format adapter for --link (for example, plain-markdown)") - cmd.Flags().String(projectFlag, "", "Project key for an attachment operation") - cmd.Flags().String(variationFlag, "", "Config and variation key for an attachment operation (config/variation)") - cmd.Flags().Bool(watchFlag, false, "Sync when managed or referenced files change") - cmd.Flags().Bool(yesFlag, false, "Apply synchronization changes without interactive confirmation") + RunE: runPrompt(client, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) { + dryRun, _ := cmd.Flags().GetBool(dryRunFlag) + return syncprompt.SyncAction{DryRun: dryRun}, nil + }), + } + + addApplyFlags(cmd, true) + cmd.AddCommand( + newWatchCmd(client), + newAddCmd(client), + newAttachCmd(client), + newDetachCmd(client), + newLinkCmd(client), + ) cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) return cmd } -func runPrompt(client resources.Client) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, _ []string) error { +type actionBuilder func(*cobra.Command, []string) (syncprompt.CommandAction, error) + +func runPrompt(client resources.Client, buildAction actionBuilder) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { workingDirectory, err := os.Getwd() if err != nil { return fmt.Errorf("get working directory: %w", err) } - - add, _ := cmd.Flags().GetBool(addFlag) - attachment, err := attachmentRequest(cmd) + action, err := buildAction(cmd, args) + if err != nil { + return err + } + policy, err := conflictPolicy(cmd) if err != nil { return err } - detach, _ := cmd.Flags().GetBool(detachFlag) - dryRun, _ := cmd.Flags().GetBool(dryRunFlag) - format, _ := cmd.Flags().GetString(formatFlag) - link, _ := cmd.Flags().GetString(linkFlag) - watch, _ := cmd.Flags().GetBool(watchFlag) yes, _ := cmd.Flags().GetBool(yesFlag) + noInput, _ := cmd.Flags().GetBool(noInputFlag) outputKind := cliflags.GetOutputKind(cmd) err = syncprompt.NewRunner(client).Run(syncprompt.Options{ @@ -91,14 +99,10 @@ func runPrompt(client resources.Client) func(*cobra.Command, []string) error { AccessToken: viper.GetString(cliflags.AccessTokenFlag), BaseURI: viper.GetString(cliflags.BaseURIFlag), OutputKind: outputKind, - Add: add, - Attachment: attachment, - Detach: detach, - DryRun: dryRun, - Format: format, - Link: link, - Watch: watch, + Action: action, + ConflictPolicy: policy, Yes: yes, + NoInput: noInput, Context: cmd.Context(), Input: cmd.InOrStdin(), Output: cmd.OutOrStdout(), @@ -111,29 +115,253 @@ func runPrompt(client resources.Client) func(*cobra.Command, []string) error { } } -func attachmentRequest(cmd *cobra.Command) (*syncprompt.AttachmentRequest, error) { - attachSkill := cmd.Flags().Changed(attachSkillFlag) - attachTool := cmd.Flags().Changed(attachToolFlag) - project, _ := cmd.Flags().GetString(projectFlag) - variation, _ := cmd.Flags().GetString(variationFlag) +func newWatchCmd(client resources.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "watch", + Short: "Synchronize when managed files change", + Example: ` # Watch files and confirm each set of changes + ldcli sync prompt watch + + # Apply watched changes without prompting and report unresolved conflicts + ldcli sync prompt watch --yes --no-input`, + Args: validatedArgs(cobra.NoArgs), + RunE: runPrompt(client, func(*cobra.Command, []string) (syncprompt.CommandAction, error) { + return syncprompt.SyncAction{Watch: true}, nil + }), + } + addApplyFlags(cmd, false) + return cmd +} + +func newAddCmd(client resources.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "add [project-key/config-key/variation-key...]", + Short: "Add prompt variations from LaunchDarkly", + Example: ` # Choose variations interactively + ldcli sync prompt add + + # Add one variation by its full selector + ldcli sync prompt add production/support/default + + # Add multiple variations without prompting + ldcli sync prompt 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) + if err != nil { + return nil, err + } + dryRun, _ := cmd.Flags().GetBool(dryRunFlag) + return syncprompt.AddAction{Variations: variations, DryRun: dryRun}, nil + }), + } + cmd.Flags().Bool(dryRunFlag, false, "Preview local files without creating them") + cmd.Flags().Bool(noInputFlag, false, "Fail instead of prompting for missing input") + return cmd +} + +func newAttachCmd(client resources.Client) *cobra.Command { + cmd := &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 + + # Attach a skill + ldcli sync prompt attach skill summarize --to production/support/default --yes`, + } + cmd.AddCommand( + newAttachKindCmd(client, syncdomain.AttachmentTool), + newAttachKindCmd(client, syncdomain.AttachmentSkill), + ) + return cmd +} + +func newAttachKindCmd(client resources.Client, kind syncdomain.AttachmentKind) *cobra.Command { + cmd := &cobra.Command{ + 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 + + # Attach a %s by key to a synced variation + ldcli sync prompt attach %s example-key \ + --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) { + target, err := optionalVariationFlag(cmd, toFlag) + if err != nil { + return nil, err + } + key := "" + if len(args) == 1 { + key = args[0] + } + return syncprompt.AttachAction{Kind: kind, Key: key, Target: target}, nil + }), + } + cmd.Flags().String(toFlag, "", "Target variation (project-key/config-key/variation-key)") + addApplyFlags(cmd, false) + return cmd +} + +func newDetachCmd(client resources.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "detach [project-key/config-key/variation-key...]", + Short: "Stop syncing local prompt variations", + Example: ` # Choose variations interactively + ldcli sync prompt detach + + # Stop syncing one variation + ldcli sync prompt detach production/support/default`, + Args: validatedArgs(cobra.MinimumNArgs(0)), + RunE: runPrompt(client, func(_ *cobra.Command, args []string) (syncprompt.CommandAction, error) { + variations, err := parseVariationSelectors(args) + if err != nil { + return nil, err + } + return syncprompt.DetachAction{Variations: variations}, nil + }), + } + cmd.Flags().Bool(noInputFlag, false, "Fail instead of prompting for missing input") + return cmd +} + +func newLinkCmd(client resources.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "link ", + Short: "Link an external prompt file", + Example: ` # Link a Markdown file as a new variation + ldcli sync prompt link prompts/support.md \ + --format plain-markdown \ + --to production/support/default \ + --model-config-key claude \ + --yes --no-input`, + Args: validatedArgs(cobra.ExactArgs(1)), + RunE: runPrompt(client, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) { + format, _ := cmd.Flags().GetString(formatFlag) + target, err := linkTarget(cmd) + if err != nil { + return nil, err + } + return syncprompt.LinkAction{File: args[0], Format: format, Target: target}, nil + }), + } + 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") + 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) + return cmd +} + +func addApplyFlags(cmd *cobra.Command, dryRun bool) { + if dryRun { + cmd.Flags().Bool(dryRunFlag, false, "Preview synchronization changes without applying them") + } + 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)") + cmd.Flags().Bool(yesFlag, false, "Apply changes without confirmation") + cmd.Flags().Bool(noInputFlag, false, "Fail instead of prompting for missing input") +} + +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) + } +} + +func parseVariationSelectors(values []string) ([]syncdomain.ResourceID, error) { + variations := make([]syncdomain.ResourceID, 0, len(values)) + for _, value := range values { + variation, err := syncdomain.ParseVariationSelector(value) + if err != nil { + return nil, err + } + variations = append(variations, variation) + } + return variations, nil +} + +func optionalVariationFlag(cmd *cobra.Command, name string) (*syncdomain.ResourceID, error) { + value, _ := cmd.Flags().GetString(name) + if value == "" { + return nil, nil + } + variation, err := syncdomain.ParseVariationSelector(value) + if err != nil { + return nil, err + } + return &variation, nil +} - if attachSkill && attachTool { - return nil, fmt.Errorf("--attach-skill and --attach-tool cannot be combined") +func linkTarget(cmd *cobra.Command) (*synclink.Target, error) { + variation, err := optionalVariationFlag(cmd, toFlag) + if err != nil { + return nil, err } - if !attachSkill && !attachTool { - if project != "" || variation != "" { - return nil, fmt.Errorf("--project and --variation require --attach-skill or --attach-tool") + modelConfig, _ := cmd.Flags().GetString(modelConfigKeyFlag) + name, _ := cmd.Flags().GetString(nameFlag) + content, _ := cmd.Flags().GetString(contentFlag) + if variation == nil { + if modelConfig != "" || name != "" || content != "" { + return nil, fmt.Errorf("--model-config-key, --name, and --content require --to") } return nil, nil } + return &synclink.Target{ + Variation: *variation, + ModelConfigKey: modelConfig, + Name: name, + Content: content, + }, nil +} + +func conflictPolicy(cmd *cobra.Command) (syncprompt.ConflictPolicy, error) { + var policy syncprompt.ConflictPolicy + if cmd.Flags().Lookup(conflictFlag) == nil { + return policy, nil + } + defaultValue, _ := cmd.Flags().GetString(conflictFlag) + if defaultValue != "" { + resolution, err := syncprompt.ParseConflictResolution(defaultValue) + if err != nil { + return policy, err + } + policy.Default = resolution + } - kind, flagName := syncdomain.AttachmentSkill, attachSkillFlag - if attachTool { - kind, flagName = syncdomain.AttachmentTool, attachToolFlag + values, _ := cmd.Flags().GetStringArray(resolveFlag) + if len(values) == 0 { + return policy, nil } - key, _ := cmd.Flags().GetString(flagName) - if key == interactiveAttachment { - key = "" + policy.Overrides = make(map[syncdomain.ResourceID]syncprompt.ConflictResolution, len(values)) + for _, value := range values { + selector, choice, ok := strings.Cut(value, "=") + if !ok { + return policy, fmt.Errorf( + "invalid --resolve %q; expected project-key/config-key/variation-key=choice", + value, + ) + } + variation, err := syncdomain.ParseVariationSelector(selector) + if err != nil { + return policy, err + } + if _, duplicate := policy.Overrides[variation]; duplicate { + return policy, fmt.Errorf("conflict resolution for %q was provided more than once", selector) + } + resolution, err := syncprompt.ParseConflictResolution(choice) + if err != nil { + return policy, err + } + policy.Overrides[variation] = resolution } - return &syncprompt.AttachmentRequest{Kind: kind, Key: key, ProjectKey: project, Variation: variation}, nil + return policy, nil } diff --git a/cmd/sync/prompt_test.go b/cmd/sync/prompt_test.go index c85627e1..97fc5ce1 100644 --- a/cmd/sync/prompt_test.go +++ b/cmd/sync/prompt_test.go @@ -7,29 +7,105 @@ import ( "github.com/stretchr/testify/require" syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncprompt "github.com/launchdarkly/ldcli/internal/sync/prompt" ) -func TestPromptCommandDefinesSyncFlags(t *testing.T) { +func TestPromptCommandUsesActionSubcommands(t *testing.T) { command := NewPromptCmd(nil) assert.Equal(t, "prompt", command.Use) - for _, name := range []string{ - addFlag, attachSkillFlag, attachToolFlag, detachFlag, dryRunFlag, formatFlag, - linkFlag, projectFlag, variationFlag, watchFlag, yesFlag, + for _, name := range []string{"watch", "add", "attach", "detach", "link"} { + child, _, err := command.Find([]string{name}) + require.NoError(t, err) + assert.Equal(t, name, child.Name()) + } + for _, name := range []string{"add", "attach-tool", "attach-skill", "detach", "link", "watch"} { + assert.Nil(t, command.Flags().Lookup(name), "legacy --%s should not exist", name) + } + add, _, err := command.Find([]string{"add"}) + require.NoError(t, err) + assert.Equal(t, "add [project-key/config-key/variation-key...]", add.Use) + link, _, err := command.Find([]string{"link"}) + require.NoError(t, err) + assert.NotNil(t, link.Flags().Lookup(modelConfigKeyFlag)) + assert.Nil(t, link.Flags().Lookup("model-config")) +} + +func TestSyncCommandIncludesExamples(t *testing.T) { + command := NewSyncCmd(nil, nil) + + assert.Contains(t, command.Example, "ldcli sync prompt") +} + +func TestPromptCommandHelpIncludesExamples(t *testing.T) { + command := NewPromptCmd(nil) + tests := map[string][]string{ + "prompt": {}, + "watch": {"watch"}, + "add": {"add"}, + "attach": {"attach"}, + "attach tool": {"attach", "tool"}, + "attach skill": {"attach", "skill"}, + "detach": {"detach"}, + "link": {"link"}, + } + + for name, path := range tests { + t.Run(name, func(t *testing.T) { + target := command + if len(path) != 0 { + var err error + target, _, err = command.Find(path) + require.NoError(t, err) + } + + assert.Contains(t, target.Example, "ldcli sync prompt") + }) + } +} + +func TestAttachSubcommandsAcceptOptionalKeyAndTarget(t *testing.T) { + command := NewPromptCmd(nil) + tool, _, err := command.Find([]string{"attach", "tool"}) + require.NoError(t, err) + require.NoError(t, tool.ParseFlags([]string{"--to", "project/config/variation"})) + + target, err := optionalVariationFlag(tool, toFlag) + require.NoError(t, err) + require.NotNil(t, target) + assert.Equal(t, syncdomain.ResourceID{ + Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/variation", + }, *target) +} + +func TestParseVariationSelectorsRejectsAmbiguousValues(t *testing.T) { + for _, selector := range []string{ + "project/config", + "project/../variation", + "project/con\\fig/variation", + "project/config/vari\x00ation", } { - assert.NotNil(t, command.Flags().Lookup(name), "missing --%s", name) + _, err := parseVariationSelectors([]string{selector}) + require.ErrorContains(t, err, "expected project-key/config-key/variation-key") } - assert.Nil(t, command.Flags().Lookup("apply")) + + variations, err := parseVariationSelectors([]string{"project/config/first", "project/config/second"}) + require.NoError(t, err) + require.Len(t, variations, 2) } -func TestAttachmentFlagAcceptsNoKeyForInteractiveSelection(t *testing.T) { +func TestConflictPolicyParsesDefaultAndOverrides(t *testing.T) { command := NewPromptCmd(nil) + require.NoError(t, command.ParseFlags([]string{ + "--conflict=launchdarkly", + "--resolve=project/config/variation=local", + })) - require.NoError(t, command.ParseFlags([]string{"--attach-tool"})) + policy, err := conflictPolicy(command) - request, err := attachmentRequest(command) require.NoError(t, err) - require.NotNil(t, request) - assert.Equal(t, syncdomain.AttachmentTool, request.Kind) - assert.Empty(t, request.Key) + assert.Equal(t, syncprompt.ConflictUseLaunchDarkly, policy.Default) + assert.Equal(t, syncprompt.ConflictUseLocal, policy.Overrides[syncdomain.ResourceID{ + Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/variation", + }]) } diff --git a/cmd/sync/sync.go b/cmd/sync/sync.go index 0e5c4158..14c4a9d8 100644 --- a/cmd/sync/sync.go +++ b/cmd/sync/sync.go @@ -16,7 +16,12 @@ func NewSyncCmd(client resources.Client, analyticsTrackerFn analytics.TrackerFn) cmd := &cobra.Command{ Use: "sync", Short: "Synchronize local resources with LaunchDarkly", - Args: cobra.MinimumNArgs(1), + Example: ` # Synchronize prompt variations + ldcli sync prompt + + # Preview prompt synchronization changes + ldcli sync prompt --dry-run`, + Args: cobra.MinimumNArgs(1), PersistentPreRun: func(cmd *cobra.Command, _ []string) { tracker := analyticsTrackerFn( viper.GetString(cliflags.AccessTokenFlag), diff --git a/internal/sync/api/attachment.go b/internal/sync/api/attachment.go index ede25699..7f93590b 100644 --- a/internal/sync/api/attachment.go +++ b/internal/sync/api/attachment.go @@ -55,7 +55,11 @@ func (client Client) SearchAttachments( response, err := client.transport.MakeRequest(client.accessToken, http.MethodGet, endpoint, "", values, nil, false) if err != nil { - return Page[syncdomain.Attachment]{}, fmt.Errorf("search %ss: %w", kind, err) + return Page[syncdomain.Attachment]{}, contextualAPIError( + err, + fmt.Sprintf("search %ss in project %q", kind, projectKey), + projectKey, + ) } switch kind { @@ -103,7 +107,11 @@ func (client Client) ReadAttachment(projectKey string, kind syncdomain.Attachmen response, err := client.transport.MakeRequest(client.accessToken, http.MethodGet, endpoint, "", nil, nil, false) if err != nil { - return syncdomain.Attachment{}, fmt.Errorf("get %s %q: %w", kind, key, err) + return syncdomain.Attachment{}, contextualAPIError( + err, + fmt.Sprintf("get %s %q in project %q", kind, key, projectKey), + projectKey, + ) } attachment, err := decodeAttachment(kind, response) if err != nil { diff --git a/internal/sync/api/catalog.go b/internal/sync/api/catalog.go index 585028d1..b3c5b705 100644 --- a/internal/sync/api/catalog.go +++ b/internal/sync/api/catalog.go @@ -106,7 +106,7 @@ func (client Client) SearchProjects(query string, limit, offset int) (Page[Proje } response, err := client.transport.MakeRequest(client.accessToken, http.MethodGet, endpoint, "", values, nil, false) if err != nil { - return Page[Project]{}, fmt.Errorf("search projects: %w", err) + return Page[Project]{}, contextualAPIError(err, "search projects", "") } var page Page[Project] @@ -133,7 +133,11 @@ func (client Client) ModelConfigs(projectKey string) ([]ModelConfig, error) { false, ) if err != nil { - return nil, fmt.Errorf("list model configs: %w", err) + return nil, contextualAPIError( + err, + fmt.Sprintf("list model configs in project %q", projectKey), + projectKey, + ) } var modelConfigs []ModelConfig @@ -172,7 +176,11 @@ func (client Client) SearchConfigs(projectKey, query string, modes []syncdomain. } response, err := client.transport.MakeRequest(client.accessToken, http.MethodGet, endpoint, "", values, nil, false) if err != nil { - return Page[Config]{}, fmt.Errorf("search configs: %w", err) + return Page[Config]{}, contextualAPIError( + err, + fmt.Sprintf("search configs in project %q", projectKey), + projectKey, + ) } var page Page[Config] @@ -204,7 +212,11 @@ func (client Client) Config(projectKey, configKey string) (Config, error) { false, ) if err != nil { - return Config{}, fmt.Errorf("get config %q: %w", configKey, err) + return Config{}, contextualAPIError( + err, + fmt.Sprintf("get config %q in project %q", configKey, projectKey), + projectKey, + ) } var config Config diff --git a/internal/sync/api/catalog_test.go b/internal/sync/api/catalog_test.go index a79341f8..c83cfa2c 100644 --- a/internal/sync/api/catalog_test.go +++ b/internal/sync/api/catalog_test.go @@ -250,7 +250,7 @@ func TestCatalogClientModelConfigsReturnsRequestError(t *testing.T) { _, err := client.ModelConfigs("project") - require.ErrorContains(t, err, "list model configs: unavailable") + require.ErrorContains(t, err, `list model configs in project "project": unavailable`) } func TestCatalogClientModelConfigsRejectsInvalidResponse(t *testing.T) { diff --git a/internal/sync/api/client.go b/internal/sync/api/client.go index 31e70cc9..68d2c1d5 100644 --- a/internal/sync/api/client.go +++ b/internal/sync/api/client.go @@ -6,7 +6,9 @@ import ( "fmt" "net/http" "net/url" + "strings" + lderrors "github.com/launchdarkly/ldcli/internal/errors" "github.com/launchdarkly/ldcli/internal/resources" syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) @@ -68,6 +70,7 @@ type createVariationRequest struct { ModelConfigKey string `json:"modelConfigKey,omitempty"` ModelConfigVersion int `json:"modelConfigVersion,omitempty"` Model map[string]any `json:"model,omitempty"` + OutputFormat map[string]any `json:"outputFormat,omitempty"` Messages []syncdomain.Message `json:"messages,omitempty"` Tools []syncdomain.AttachmentRef `json:"tools,omitempty"` Skills []syncdomain.AttachmentRef `json:"skills,omitempty"` @@ -79,6 +82,7 @@ type updateVariationRequest struct { ModelConfigKey string `json:"modelConfigKey"` ModelConfigVersion int `json:"modelConfigVersion,omitempty"` Model map[string]any `json:"model"` + OutputFormat map[string]any `json:"outputFormat"` Messages *[]syncdomain.Message `json:"messages,omitempty"` Tools *[]syncdomain.AttachmentRef `json:"tools,omitempty"` Skills *[]syncdomain.AttachmentRef `json:"skills,omitempty"` @@ -133,7 +137,11 @@ func (client Client) GetSyncManifest(projectKey, source string) (SyncManifest, e false, ) if err != nil { - return SyncManifest{}, fmt.Errorf("get sync manifest for project %q: %w", projectKey, err) + return SyncManifest{}, contextualAPIError( + err, + fmt.Sprintf("get sync manifest for project %q", projectKey), + projectKey, + ) } var manifest SyncManifest @@ -201,7 +209,11 @@ func (client Client) ModelConfig(projectKey, modelConfigKey string) (ModelConfig response, err := client.transport.MakeRequest(client.accessToken, http.MethodGet, endpoint, "", nil, nil, false) if err != nil { - return ModelConfig{}, fmt.Errorf("get model config %q: %w", modelConfigKey, err) + return ModelConfig{}, contextualAPIError( + err, + fmt.Sprintf("get model config %q in project %q", modelConfigKey, projectKey), + projectKey, + ) } var modelConfig ModelConfig @@ -248,6 +260,7 @@ func (client Client) CreateVariation(projectKey, configKey string, variation syn ModelConfigKey: variation.ModelConfigKey, ModelConfigVersion: variation.ModelConfigVersion, Model: variation.Model, + OutputFormat: variation.OutputFormat, Tools: variation.Tools, Skills: variation.Skills, } @@ -293,11 +306,18 @@ func (client Client) UpdateVariation(projectKey, configKey string, variation syn if model == nil { model = map[string]any{} } + outputFormat := variation.OutputFormat + if outputFormat == nil { + // The API treats an omitted or null outputFormat as unchanged. Send an + // empty object when the local field is absent so removing it also syncs. + outputFormat = map[string]any{} + } request := updateVariationRequest{ Name: variation.Name, ModelConfigKey: variation.ModelConfigKey, ModelConfigVersion: variation.ModelConfigVersion, Model: model, + OutputFormat: outputFormat, } if variation.Tools != nil { request.Tools = &variation.Tools @@ -407,24 +427,59 @@ func newMutationError(action, variationKey string, err error) error { func newResourceMutationError(action, resource, key string, err error) error { _, definitiveResponse := responseStatusCode(err) + contextual := contextualAPIError( + err, + fmt.Sprintf("%s %s %q", action, resource, key), + "", + ) return mutationError{ - err: err, - message: fmt.Sprintf("%s %s %q: %s", action, resource, key, err), + err: contextual, + message: contextual.Error(), uncertain: !definitiveResponse, } } func responseStatusCode(err error) (int, bool) { + response, ok := responseError(err) + if !ok { + return 0, false + } + status, ok := response["statusCode"].(float64) + return int(status), ok && status != 0 +} + +// contextualAPIError keeps the API's structured status fields while adding +// the resource identity needed to act on the failure. +func contextualAPIError(err error, context, projectKey string) error { + response, ok := responseError(err) + if !ok { + return fmt.Errorf("%s: %w", context, err) + } + status, _ := response["statusCode"].(float64) + if int(status) == http.StatusNotFound && projectKey != "" { + response["message"] = context + response["suggestion"] = fmt.Sprintf( + "Verify the resource key and that it belongs to project %q.", + projectKey, + ) + } else if message, _ := response["message"].(string); message != "" { + response["message"] = context + ": " + strings.ReplaceAll(message, "AI config", "config") + } else { + response["message"] = context + } + body, _ := json.Marshal(response) + return lderrors.NewErrorWrapped(string(body), err) +} + +func responseError(err error) (map[string]any, bool) { for current := err; current != nil; current = errors.Unwrap(current) { - var response struct { - StatusCode int `json:"statusCode"` - } - if json.Unmarshal([]byte(current.Error()), &response) == nil && response.StatusCode != 0 { - return response.StatusCode, true + var response map[string]any + if json.Unmarshal([]byte(current.Error()), &response) == nil && len(response) != 0 { + return response, true } } - return 0, false + return nil, false } // IsConflict reports whether a wrapped LaunchDarkly API error has a 409 status. diff --git a/internal/sync/api/client_test.go b/internal/sync/api/client_test.go index 3e39b100..16d6e8fd 100644 --- a/internal/sync/api/client_test.go +++ b/internal/sync/api/client_test.go @@ -156,7 +156,7 @@ func TestClientVariationReturnsTransportError(t *testing.T) { _, err := client.ReadVariation("project", "support", "helpful") - require.ErrorContains(t, err, `get config "support": unavailable`) + require.ErrorContains(t, err, `get config "support" in project "project": unavailable`) } func TestClientVariationRejectsInvalidResponse(t *testing.T) { @@ -212,6 +212,9 @@ func TestClientCreateVariation(t *testing.T) { Content: "Be concise.", }}, Instructions: "stale agent instructions", + OutputFormat: map[string]any{ + "type": "object", + }, } err := client.CreateVariation("project", "support", variation) @@ -238,10 +241,10 @@ func TestClientCreateVariation(t *testing.T) { "modelName": "claude-3-5-sonnet", "parameters": {"temperature": 0.2} }, + "outputFormat": {"type": "object"}, "messages": [{"role": "system", "content": "Be concise."}] }`, string(request.Body)) assert.NotContains(t, string(request.Body), `"mode"`) - assert.NotContains(t, string(request.Body), `"outputFormat"`) assert.NotContains(t, string(request.Body), `"instructions"`) } @@ -292,28 +295,6 @@ func TestClientMutationRecognizesDefinitiveAPIError(t *testing.T) { assert.False(t, MutationMayHaveSucceeded(err)) } -func TestClientCreateVariationRejectsUnsupportedDirectAPIFields(t *testing.T) { - tests := map[string]syncdomain.Variation{ - "output format": func() syncdomain.Variation { - variation := testVariation(syncdomain.VariationModeAgent) - variation.OutputFormat = map[string]any{"type": "object"} - return variation - }(), - } - - for name, variation := range tests { - t.Run(name, func(t *testing.T) { - transport := &recordingClient{} - client := NewClient(transport, "token", "https://example.com") - - err := client.CreateVariation("project", "support", variation) - - require.Error(t, err) - assert.Empty(t, transport.Requests) - }) - } -} - func TestClientCreateVariationReturnsEncodeError(t *testing.T) { transport := &recordingClient{} client := NewClient(transport, "token", "https://example.com") @@ -335,6 +316,7 @@ func TestClientUpdateVariation(t *testing.T) { variation := testVariation(syncdomain.VariationModeAgent) variation.Name = "Very helpful" variation.ModelConfigVersion = 3 + variation.OutputFormat = map[string]any{"type": "object"} err := client.UpdateVariation("project", "support", variation) @@ -357,11 +339,11 @@ func TestClientUpdateVariation(t *testing.T) { "instructions": "Help the user.", "modelConfigKey": "claude", "modelConfigVersion": 3, - "model": {"modelName": "claude-3-5-sonnet"} + "model": {"modelName": "claude-3-5-sonnet"}, + "outputFormat": {"type": "object"} }`, string(request.Body)) assert.NotContains(t, string(request.Body), `"key"`) assert.NotContains(t, string(request.Body), `"mode"`) - assert.NotContains(t, string(request.Body), `"outputFormat"`) } func TestClientUpdateVariationSendsEmptyOwnedFieldsToClearThem(t *testing.T) { @@ -380,7 +362,8 @@ func TestClientUpdateVariationSendsEmptyOwnedFieldsToClearThem(t *testing.T) { "name": "Helpful", "instructions": "", "modelConfigKey": "", - "model": {} + "model": {}, + "outputFormat": {} }`, string(transport.Requests[0].Body)) } @@ -397,6 +380,7 @@ func TestClientUpdateCompletionVariationOmitsAgentFields(t *testing.T) { "name": "Helpful", "modelConfigKey": "claude", "model": {"modelName": "claude-3-5-sonnet"}, + "outputFormat": {}, "messages": [{"role": "system", "content": "Help the user."}] }`, string(transport.Requests[0].Body)) assert.NotContains(t, string(transport.Requests[0].Body), `"instructions"`) @@ -431,28 +415,6 @@ func TestClientUpdateVariationReturnsTransportError(t *testing.T) { require.ErrorContains(t, err, `update config variation "helpful": conflict`) } -func TestClientUpdateVariationRejectsUnsupportedDirectAPIFields(t *testing.T) { - tests := map[string]syncdomain.Variation{ - "output format": func() syncdomain.Variation { - variation := testVariation(syncdomain.VariationModeAgent) - variation.OutputFormat = map[string]any{"type": "object"} - return variation - }(), - } - - for name, variation := range tests { - t.Run(name, func(t *testing.T) { - transport := &recordingClient{} - client := NewClient(transport, "token", "https://example.com") - - err := client.UpdateVariation("project", "support", variation) - - require.Error(t, err) - assert.Empty(t, transport.Requests) - }) - } -} - func TestClientUpdateVariationReturnsEncodeError(t *testing.T) { transport := &recordingClient{} client := NewClient(transport, "token", "https://example.com") @@ -562,6 +524,46 @@ func TestIsConflictRecognizesAPIStatus(t *testing.T) { assert.False(t, IsConflict(errors.New(`{"code":"invalid_request","statusCode":400}`))) } +func TestContextualAPIErrorPreservesResponseFields(t *testing.T) { + err := contextualAPIError( + errors.New(`{ + "code":"not_found", + "message":"AI config not found", + "statusCode":404, + "suggestion":"generic suggestion" + }`), + `get config "agent" in project "default"`, + "default", + ) + + assert.JSONEq(t, `{ + "code":"not_found", + "message":"get config \"agent\" in project \"default\"", + "statusCode":404, + "suggestion":"Verify the resource key and that it belongs to project \"default\"." + }`, err.Error()) +} + +func TestContextualAPIErrorPreservesSpecificMutationFailure(t *testing.T) { + err := contextualAPIError( + errors.New(`{ + "code":"not_found", + "message":"model config version not found", + "statusCode":404, + "suggestion":"Select an existing model config version." + }`), + `update config variation "default"`, + "", + ) + + assert.JSONEq(t, `{ + "code":"not_found", + "message":"update config variation \"default\": model config version not found", + "statusCode":404, + "suggestion":"Select an existing model config version." + }`, err.Error()) +} + func testVariation(mode syncdomain.VariationMode) syncdomain.Variation { return syncdomain.Variation{ Mode: mode, diff --git a/internal/sync/bootstrap/bootstrap.go b/internal/sync/bootstrap/bootstrap.go index 2f3d0864..63ecc58c 100644 --- a/internal/sync/bootstrap/bootstrap.go +++ b/internal/sync/bootstrap/bootstrap.go @@ -22,6 +22,7 @@ import ( type Catalog interface { SearchProjects(query string, limit, offset int) (syncapi.Page[syncapi.Project], error) SearchConfigs(projectKey, query string, modes []syncdomain.VariationMode, limit, offset int) (syncapi.Page[syncapi.Config], error) + Config(projectKey, configKey string) (syncapi.Config, error) } type AttachmentReader interface { @@ -44,14 +45,24 @@ type Options struct { Output io.Writer Initial bool DryRun bool + Selections []syncdomain.ResourceID + NoInput bool } -// Run interactively selects prompt variations and writes their local wrappers. +// Run resolves selected prompt variations and writes their local wrappers. func Run(options Options) error { + if len(options.Selections) != 0 { + files, err := selectVariationFilesByID(options) + if err != nil { + return err + } + return finishSelection(options, files) + } + if options.NoInput { + return fmt.Errorf("variation selectors are required with --no-input") + } if !syncinteractive.StreamsAreTerminal(options.Input, options.Output) { - return fmt.Errorf( - "interactive prompt selection requires a terminal; run this command in a terminal", - ) + return fmt.Errorf("interactive prompt selection requires a terminal; use selectors with --no-input") } files, canceled, err := selectVariationFiles(options) @@ -64,6 +75,68 @@ func Run(options Options) error { return finishSelection(options, files) } +func selectVariationFilesByID(options Options) ([]synclocal.VariationFile, error) { + configs := make(map[string]syncapi.Config) + seen := make(map[syncdomain.ResourceID]struct{}, len(options.Selections)) + files := make([]synclocal.VariationFile, 0, len(options.Selections)) + + for _, selection := range options.Selections { + if selection.Kind != syncdomain.KindVariation { + return nil, fmt.Errorf("cannot add %s resource", selection.Kind) + } + if _, duplicate := seen[selection]; duplicate { + return nil, fmt.Errorf("variation %s/%s was selected more than once", selection.ProjectKey, selection.LookupKey) + } + seen[selection] = struct{}{} + + configKey, variationKey, ok := strings.Cut(selection.LookupKey, "/") + if !ok { + return nil, fmt.Errorf("invalid variation %q", selection.LookupKey) + } + cacheKey := selection.ProjectKey + "/" + configKey + config, ok := configs[cacheKey] + if !ok { + var err error + config, err = options.Catalog.Config(selection.ProjectKey, configKey) + if err != nil { + return nil, err + } + configs[cacheKey] = config + } + + variationIndex := slices.IndexFunc(config.Variations, func(variation syncdomain.Variation) bool { + return variation.Key == variationKey + }) + if variationIndex < 0 { + return nil, fmt.Errorf( + "variation %q does not exist in config %q in project %q", + variationKey, + configKey, + selection.ProjectKey, + ) + } + exists, err := options.Store.VariationExists(selection.ProjectKey, configKey, variationKey) + if err != nil { + return nil, err + } + if exists { + return nil, fmt.Errorf("variation %s/%s is already synced", selection.ProjectKey, selection.LookupKey) + } + + variation := config.Variations[variationIndex] + if err := hydrateAttachments(options.Attachments, selection.ProjectKey, &variation); err != nil { + return nil, err + } + files = append(files, synclocal.VariationFile{ + ProjectKey: selection.ProjectKey, + ConfigKey: configKey, + Upsert: true, + Variation: variation, + }) + } + return files, nil +} + // selectVariationFiles guides the user from project to config to variations // and converts the selections into local wrapper definitions. func selectVariationFiles(options Options) ([]synclocal.VariationFile, bool, error) { diff --git a/internal/sync/bootstrap/bootstrap_test.go b/internal/sync/bootstrap/bootstrap_test.go index 829a2c82..d561c447 100644 --- a/internal/sync/bootstrap/bootstrap_test.go +++ b/internal/sync/bootstrap/bootstrap_test.go @@ -88,6 +88,33 @@ func TestRunRequiresTerminal(t *testing.T) { ) } +func TestRunUsesExplicitVariationSelectorsWithoutTerminal(t *testing.T) { + root := t.TempDir() + var output bytes.Buffer + catalog := &fakeCatalog{config: syncapi.Config{ + Key: "config", + Mode: syncdomain.VariationModeAgent, + Variations: []syncdomain.Variation{{ + Key: "variation", Name: "Variation", Mode: syncdomain.VariationModeAgent, Instructions: "Be helpful.", + }}, + }} + + err := Run(Options{ + Catalog: catalog, + Store: synclocal.NewStore(root), + Input: bytes.NewBuffer(nil), + Output: &output, + Initial: true, + DryRun: true, + Selections: []syncdomain.ResourceID{{ + Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/variation", + }}, + }) + + require.NoError(t, err) + assert.Contains(t, output.String(), "Would create: .launchdarkly/project/configs/config/variation.prompt.md") +} + func TestFinishSelectionDryRunDoesNotCreateFiles(t *testing.T) { root := t.TempDir() var output bytes.Buffer @@ -386,7 +413,9 @@ second ) } -type fakeCatalog struct{} +type fakeCatalog struct { + config syncapi.Config +} var _ Catalog = &fakeCatalog{} @@ -398,6 +427,10 @@ func (*fakeCatalog) SearchConfigs(string, string, []syncdomain.VariationMode, in return syncapi.Page[syncapi.Config]{}, nil } +func (catalog *fakeCatalog) Config(string, string) (syncapi.Config, error) { + return catalog.config, nil +} + type failingManifestStore struct{} func (failingManifestStore) Load([]string) (syncmanifest.Manifest, error) { diff --git a/internal/sync/detach/detach.go b/internal/sync/detach/detach.go index 3a721a09..0e92c9a6 100644 --- a/internal/sync/detach/detach.go +++ b/internal/sync/detach/detach.go @@ -31,11 +31,20 @@ type Options struct { ProjectKeys []string Input io.Reader Output io.Writer + Selections []Resource + NoInput bool } -// Run lets the user select resources and removes their local sync state. +// Run resolves selected resources and removes their local sync state. func Run(options Options) error { - resources, manifest, err := loadResources(options.RepositoryRoot, options.Manifest, options.ProjectKeys) + projectKeys := append([]string(nil), options.ProjectKeys...) + for _, selection := range options.Selections { + projectKeys = append(projectKeys, selection.ProjectKey) + } + slices.Sort(projectKeys) + projectKeys = slices.Compact(projectKeys) + + resources, manifest, err := loadResources(options.RepositoryRoot, options.Manifest, projectKeys) if err != nil { return err } @@ -43,31 +52,40 @@ func Run(options Options) error { _ = syncconsole.New(options.Output).Line("No resources are currently synced.") return nil } - if !syncinteractive.StreamsAreTerminal(options.Input, options.Output) { - return fmt.Errorf("interactive resource selection requires a terminal; run this command in a terminal") - } - - choices := make([]syncinteractive.Choice[Resource], 0, len(resources)) - for _, resource := range resources { - choices = append(choices, syncinteractive.Choice[Resource]{ - Title: resource.ProjectKey + "/" + resource.LookupKey, - Description: string(resource.Kind), - Value: resource, - }) - } - selected, canceled, err := syncinteractive.MultiSelect( - options.Input, - options.Output, - "Select resources to detach", - "Detached resources remain in LaunchDarkly.", - choices, - ) - if err != nil { + + selected := options.Selections + if len(selected) == 0 { + if options.NoInput { + return fmt.Errorf("variation selectors are required with --no-input") + } + if !syncinteractive.StreamsAreTerminal(options.Input, options.Output) { + return fmt.Errorf("interactive resource selection requires a terminal; use selectors with --no-input") + } + choices := make([]syncinteractive.Choice[Resource], 0, len(resources)) + for _, resource := range resources { + choices = append(choices, syncinteractive.Choice[Resource]{ + Title: resource.ProjectKey + "/" + resource.LookupKey, + Description: string(resource.Kind), + Value: resource, + }) + } + var canceled bool + selected, canceled, err = syncinteractive.MultiSelect( + options.Input, + options.Output, + "Select resources to detach", + "Detached resources remain in LaunchDarkly.", + choices, + ) + if err != nil { + return err + } + if canceled { + return nil + } + } else if err := validateSelections(resources, selected); err != nil { return err } - if canceled { - return nil - } if err := detachResources(options, manifest, selected); err != nil { return err } @@ -80,6 +98,20 @@ func Run(options Options) error { return nil } +func validateSelections(available, selected []Resource) error { + seen := make(map[Resource]struct{}, len(selected)) + for _, selection := range selected { + if _, duplicate := seen[selection]; duplicate { + return fmt.Errorf("variation %s/%s was selected more than once", selection.ProjectKey, selection.LookupKey) + } + seen[selection] = struct{}{} + if !slices.Contains(available, selection) { + return fmt.Errorf("variation %s/%s is not synced", selection.ProjectKey, selection.LookupKey) + } + } + return nil +} + // loadResources returns the union of local wrappers and manifested resources. func loadResources( repositoryRoot string, diff --git a/internal/sync/detach/detach_test.go b/internal/sync/detach/detach_test.go index 6d5ec356..c5ac79fc 100644 --- a/internal/sync/detach/detach_test.go +++ b/internal/sync/detach/detach_test.go @@ -189,6 +189,35 @@ func TestRunRequiresTerminalWhenResourcesExist(t *testing.T) { require.ErrorContains(t, err, "requires a terminal") } +func TestRunUsesExplicitSelectionsWithoutTerminal(t *testing.T) { + root := t.TempDir() + store := synclocal.NewStore(root) + _, err := store.Add([]synclocal.VariationFile{{ + ProjectKey: "project", ConfigKey: "config", Variation: testVariation("prompt"), + }}) + require.NoError(t, err) + selection := Resource{ + Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/prompt", + } + manifestStore := newMemoryManifestStore() + + err = Run(Options{ + RepositoryRoot: root, + Store: store, + Manifest: manifestStore, + Input: bytes.NewBuffer(nil), + Output: bytes.NewBuffer(nil), + Selections: []Resource{selection}, + NoInput: true, + }) + + require.NoError(t, err) + exists, err := store.VariationExists("project", "config", "prompt") + require.NoError(t, err) + assert.False(t, exists) + assert.Equal(t, []string{"project"}, manifestStore.loadedProjectKeys) +} + func TestRunReportsWhenNoResourcesAreSynced(t *testing.T) { root := t.TempDir() var output bytes.Buffer @@ -206,14 +235,16 @@ func TestRunReportsWhenNoResourcesAreSynced(t *testing.T) { } type memoryManifestStore struct { - manifest syncmanifest.Manifest + manifest syncmanifest.Manifest + loadedProjectKeys []string } func newMemoryManifestStore() *memoryManifestStore { return &memoryManifestStore{manifest: syncmanifest.New()} } -func (store *memoryManifestStore) Load([]string) (syncmanifest.Manifest, error) { +func (store *memoryManifestStore) Load(projectKeys []string) (syncmanifest.Manifest, error) { + store.loadedProjectKeys = append([]string(nil), projectKeys...) return store.manifest, nil } diff --git a/internal/sync/fingerprint.go b/internal/sync/fingerprint.go index 53f367c5..65efb0af 100644 --- a/internal/sync/fingerprint.go +++ b/internal/sync/fingerprint.go @@ -179,8 +179,6 @@ func validateDirectAPIVariationFields(variation Variation) error { return fmt.Errorf("variation key is required") case variation.Name == "": return fmt.Errorf("variation name is required") - case len(variation.OutputFormat) != 0: - return fmt.Errorf("outputFormat is not supported by direct config variation APIs") } return nil } diff --git a/internal/sync/fingerprint_test.go b/internal/sync/fingerprint_test.go index 17c4f550..9761a8b7 100644 --- a/internal/sync/fingerprint_test.go +++ b/internal/sync/fingerprint_test.go @@ -283,7 +283,7 @@ func TestFingerprintAttachmentTracksCanonicalContentOnly(t *testing.T) { require.NotEqual(t, originalSkill, updatedSkill) } -func TestValidateDirectAPIVariationSupportsModelConfigVersion(t *testing.T) { +func TestDirectAPIVariationSupportsVersionAndOutputFormat(t *testing.T) { base := Variation{Mode: VariationModeAgent, Key: "default", Name: "Default"} withVersion := base @@ -292,7 +292,13 @@ func TestValidateDirectAPIVariationSupportsModelConfigVersion(t *testing.T) { withOutput := base withOutput.OutputFormat = map[string]any{"type": "json"} - require.ErrorContains(t, ValidateDirectAPIVariation(withOutput), "outputFormat") + require.NoError(t, ValidateDirectAPIVariation(withOutput)) + + baseFingerprint, err := FingerprintVariation("project", "config/default", base) + require.NoError(t, err) + outputFingerprint, err := FingerprintVariation("project", "config/default", withOutput) + require.NoError(t, err) + require.NotEqual(t, baseFingerprint, outputFingerprint) } func TestValidateDirectAPIVariationRejectsSkillsForCompletionMode(t *testing.T) { diff --git a/internal/sync/link/link.go b/internal/sync/link/link.go index 60bdf034..c5eeeba4 100644 --- a/internal/sync/link/link.go +++ b/internal/sync/link/link.go @@ -25,6 +25,7 @@ import ( type Catalog interface { SearchProjects(query string, limit, offset int) (syncapi.Page[syncapi.Project], error) SearchConfigs(projectKey, query string, modes []syncdomain.VariationMode, limit, offset int) (syncapi.Page[syncapi.Config], error) + Config(projectKey, configKey string) (syncapi.Config, error) ModelConfigs(projectKey string) ([]syncapi.ModelConfig, error) } @@ -38,6 +39,17 @@ type Options struct { Format string Input io.Reader Output io.Writer + Target *Target + NoInput bool +} + +// Target identifies a non-interactive link destination and any source +// metadata that is missing from the referenced file. +type Target struct { + Variation syncdomain.ResourceID + ModelConfigKey string + Name string + Content string } // Selection is the LaunchDarkly destination selected for a linked prompt. @@ -56,17 +68,21 @@ type linkedPrompt struct { content []byte } -// Run interactively selects a destination and creates the local linked -// variation wrapper. +// Run resolves a destination and creates the local linked variation wrapper. func Run(options Options) (string, error) { - if !syncinteractive.StreamsAreTerminal(options.Input, options.Output) { - return "", fmt.Errorf("interactive prompt linking requires a terminal; run this command in a terminal") - } - prompt, err := readLinkedPrompt(options) if err != nil { return "", err } + if options.Target != nil { + return linkToTarget(options, prompt, *options.Target) + } + if options.NoInput { + return "", fmt.Errorf("--to and --model-config-key are required with --no-input") + } + if !syncinteractive.StreamsAreTerminal(options.Input, options.Output) { + return "", fmt.Errorf("interactive prompt linking requires a terminal; use --to and --model-config-key with --no-input") + } console := syncconsole.New(options.Output) project, canceled, err := syncinteractive.SearchSelect(syncinteractive.SearchOptions[syncapi.Project]{ @@ -169,6 +185,77 @@ func Run(options Options) (string, error) { }, prompt) } +func linkToTarget(options Options, prompt linkedPrompt, target Target) (string, error) { + if target.Variation.Kind != syncdomain.KindVariation { + return "", fmt.Errorf("link destination must be a variation") + } + configKey, variationKey, ok := strings.Cut(target.Variation.LookupKey, "/") + if !ok { + return "", fmt.Errorf("invalid variation %q", target.Variation.LookupKey) + } + if prompt.parsed.Key != "" && prompt.parsed.Key != variationKey { + return "", fmt.Errorf( + "linked file key %q does not match destination key %q", + prompt.parsed.Key, + variationKey, + ) + } + if target.Name != "" && strings.TrimSpace(target.Name) == "" { + return "", fmt.Errorf("--name cannot be blank") + } + if target.Content != "" && strings.TrimSpace(target.Content) == "" { + return "", fmt.Errorf("--content cannot be blank") + } + + config, err := options.Catalog.Config(target.Variation.ProjectKey, configKey) + if err != nil { + return "", err + } + modelConfigs, err := options.Catalog.ModelConfigs(target.Variation.ProjectKey) + if err != nil { + return "", err + } + var modelConfig syncapi.ModelConfig + for _, candidate := range modelConfigs { + if candidate.Key == target.ModelConfigKey { + modelConfig = candidate + break + } + } + if modelConfig.Key == "" { + return "", fmt.Errorf("model config %q was not found", target.ModelConfigKey) + } + + name := prompt.parsed.Name + if target.Name != "" { + if name != "" && name != target.Name { + return "", fmt.Errorf("linked file name %q does not match --name %q", name, target.Name) + } + name = target.Name + } + if name == "" { + name = displayName(variationKey) + } + if len(prompt.parsed.Messages) == 0 && target.Content == "" { + return "", fmt.Errorf("--content is required when the linked file has no prompt content") + } + if len(prompt.parsed.Messages) != 0 && target.Content != "" { + return "", fmt.Errorf("--content cannot be used when the linked file already has prompt content") + } + prompt, err = addMissingPromptContent(prompt, config.Mode, variationKey, name, target.Content) + if err != nil { + return "", err + } + + return createLinkedPrompt(options, Selection{ + Project: syncapi.Project{Key: target.Variation.ProjectKey}, + Config: config, + ModelConfig: modelConfig, + Key: variationKey, + Name: name, + }, prompt) +} + // addMissingPromptContent renders entered content in memory so destination // validation can finish before the referenced file is changed. func addMissingPromptContent( diff --git a/internal/sync/link/link_test.go b/internal/sync/link/link_test.go index dbe1a66a..ea622d69 100644 --- a/internal/sync/link/link_test.go +++ b/internal/sync/link/link_test.go @@ -13,6 +13,70 @@ import ( syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" ) +func TestRunUsesExplicitTargetWithoutTerminal(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Be helpful.\n"), 0o644)) + target := syncdomain.ResourceID{ + Kind: syncdomain.KindVariation, ProjectKey: "production", LookupKey: "support/support-agent", + } + + path, err := Run(Options{ + Catalog: &fakeCatalog{ + config: syncapi.Config{Key: "support", Mode: syncdomain.VariationModeAgent}, + model: syncapi.ModelConfig{Key: "claude", ID: "claude-3", Version: 2}, + }, + Store: synclocal.NewStore(root), + RepositoryRoot: root, + WorkingDirectory: root, + File: "prompt.md", + Format: syncreference.PlainMarkdown, + NoInput: true, + Target: &Target{ + Variation: target, ModelConfigKey: "claude", + }, + }) + + require.NoError(t, err) + require.Equal(t, "production/configs/support/support-agent.prompt.md", path) + wrapper, err := os.ReadFile(filepath.Join(root, syncdomain.RootDir, filepath.FromSlash(path))) + require.NoError(t, err) + require.Contains(t, string(wrapper), "name: Support agent") +} + +func TestRunRejectsBlankExplicitMetadata(t *testing.T) { + tests := map[string]Target{ + "name": { + Name: " ", + }, + "content": { + Content: "\t", + }, + } + + for field, target := range tests { + t.Run(field, func(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), nil, 0o644)) + target.Variation = syncdomain.ResourceID{ + Kind: syncdomain.KindVariation, ProjectKey: "production", LookupKey: "support/support-agent", + } + target.ModelConfigKey = "claude" + + _, err := Run(Options{ + Catalog: &fakeCatalog{}, + Store: synclocal.NewStore(root), + RepositoryRoot: root, + WorkingDirectory: root, + File: "prompt.md", + Format: syncreference.PlainMarkdown, + Target: &target, + }) + + require.ErrorContains(t, err, "--"+field+" cannot be blank") + }) + } +} + func TestCreateWritesLinkedVariationWrapper(t *testing.T) { root := t.TempDir() require.NoError(t, os.Mkdir(filepath.Join(root, "prompts"), 0o755)) @@ -158,3 +222,24 @@ func TestRequiredValueRejectsBlankInput(t *testing.T) { require.Error(t, requiredValue("variation name")(" ")) require.NoError(t, requiredValue("variation name")("Custom name")) } + +type fakeCatalog struct { + config syncapi.Config + model syncapi.ModelConfig +} + +func (catalog *fakeCatalog) Config(string, string) (syncapi.Config, error) { + return catalog.config, nil +} + +func (*fakeCatalog) SearchProjects(string, int, int) (syncapi.Page[syncapi.Project], error) { + return syncapi.Page[syncapi.Project]{}, nil +} + +func (*fakeCatalog) SearchConfigs(string, string, []syncdomain.VariationMode, int, int) (syncapi.Page[syncapi.Config], error) { + return syncapi.Page[syncapi.Config]{}, nil +} + +func (catalog *fakeCatalog) ModelConfigs(string) ([]syncapi.ModelConfig, error) { + return []syncapi.ModelConfig{catalog.model}, nil +} diff --git a/internal/sync/prompt/acceptance_test.go b/internal/sync/prompt/acceptance_test.go index 3693e84c..bb8e9f02 100644 --- a/internal/sync/prompt/acceptance_test.go +++ b/internal/sync/prompt/acceptance_test.go @@ -18,6 +18,7 @@ import ( "github.com/launchdarkly/ldcli/cmd" "github.com/launchdarkly/ldcli/internal/analytics" + lderrors "github.com/launchdarkly/ldcli/internal/errors" "github.com/launchdarkly/ldcli/internal/resources" syncdomain "github.com/launchdarkly/ldcli/internal/sync" syncapi "github.com/launchdarkly/ldcli/internal/sync/api" @@ -36,6 +37,19 @@ type directAPI struct { requests []string } +type notFoundAPI struct{} + +func (notFoundAPI) MakeRequest(string, string, string, string, url.Values, []byte, bool) ([]byte, error) { + return nil, lderrors.NewError( + `{"code":"not_found","message":"AI config not found","statusCode":404,` + + `"suggestion":"Resource not found. Verify the selected project."}`, + ) +} + +func (notFoundAPI) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { + return nil, nil +} + type versionedTool struct { syncdomain.Tool Version int `json:"version"` @@ -157,6 +171,7 @@ func (api *directAPI) MakeRequest( ModelConfigKey string `json:"modelConfigKey"` ModelConfigVersion int `json:"modelConfigVersion"` Model map[string]any `json:"model"` + OutputFormat map[string]any `json:"outputFormat"` Tools *[]syncdomain.AttachmentRef `json:"tools"` Skills *[]syncdomain.AttachmentRef `json:"skills"` } @@ -181,7 +196,7 @@ func (api *directAPI) MakeRequest( Mode: syncdomain.VariationModeAgent, Key: key, Name: update.Name, Instructions: update.Instructions, ModelConfigKey: update.ModelConfigKey, ModelConfigVersion: update.ModelConfigVersion, Model: update.Model, - Tools: tools, Skills: skills, + OutputFormat: update.OutputFormat, Tools: tools, Skills: skills, } api.variationState = "published" } @@ -209,6 +224,26 @@ func TestPromptDryRunUsesOnlyExistingReadAPI(t *testing.T) { } } +func TestPromptAddReportsContextForNotFoundConfig(t *testing.T) { + root := initRepository(t) + + _, _, err := runPrompt( + t, + root, + notFoundAPI{}, + "add", + "default/this-is-an-agent/this-variation", + "--output=plaintext", + ) + + require.Error(t, err) + assert.ErrorContains(t, err, `get config "this-is-an-agent" in project "default"`) + assert.NotContains(t, err.Error(), "AI config") + assert.ErrorContains(t, err, "(code: not_found)") + assert.ErrorContains(t, err, `Suggestion: Verify the resource key and that it belongs to project "default".`) + assert.NotContains(t, err.Error(), "unknown error occurred") +} + func TestPromptFirstSyncAdoptsMatchingStateWithoutMutation(t *testing.T) { root := initRepository(t) local := variation("Matching") @@ -267,9 +302,10 @@ func TestPromptAttachesLatestToolToManagedVariation(t *testing.T) { t, root, api, - "--attach-tool=search", - "--project=production", - "--variation=support/default", + "attach", + "tool", + "search", + "--to=production/support/default", "--yes", ) @@ -309,9 +345,10 @@ func TestPromptAttachesLatestSkillAsMarkdownFile(t *testing.T) { t, root, api, - "--attach-skill=support", - "--project=production", - "--variation=support/default", + "attach", + "skill", + "support", + "--to=production/support/default", "--yes", ) @@ -342,9 +379,10 @@ func TestPromptRejectsSkillAttachmentForCompletionVariation(t *testing.T) { t, root, api, - "--attach-skill=support", - "--project=production", - "--variation=support/default", + "attach", + "skill", + "support", + "--to=production/support/default", "--yes", ) @@ -565,6 +603,47 @@ func TestPromptPullsServerChangeAndAdvancesManifest(t *testing.T) { assertManifestFingerprint(t, root, server) } +func TestPromptRoundTripsOutputFormat(t *testing.T) { + root := initRepository(t) + baseline := variation("Baseline") + server := variation("Server") + server.OutputFormat = map[string]any{"type": "object"} + writeVariation(t, root, baseline, false) + writeManifest(t, root, baseline) + api := &directAPI{variation: pointer(server)} + + _, _, err := runPrompt(t, root, api, "--yes") + require.NoError(t, err) + + resources, err := synclocal.Compile(os.DirFS(root)) + require.NoError(t, err) + require.Len(t, resources, 1) + var pulled syncdomain.Variation + require.NoError(t, json.Unmarshal(resources[0].Payload, &pulled)) + assert.Equal(t, server.OutputFormat, pulled.OutputFormat) + + local := server + local.OutputFormat = map[string]any{"type": "array"} + _, err = synclocal.NewStore(root).ReplaceVariations([]synclocal.VariationReplacement{{ + ProjectKey: "production", ConfigKey: "support", Variation: local, + }}) + require.NoError(t, err) + + _, _, err = runPrompt(t, root, api, "--yes") + require.NoError(t, err) + assert.Equal(t, local.OutputFormat, api.variation.OutputFormat) + + local.OutputFormat = nil + _, err = synclocal.NewStore(root).ReplaceVariations([]synclocal.VariationReplacement{{ + ProjectKey: "production", ConfigKey: "support", Variation: local, + }}) + require.NoError(t, err) + + _, _, err = runPrompt(t, root, api, "--yes") + require.NoError(t, err) + assert.Empty(t, api.variation.OutputFormat) +} + func TestPromptPullPreservesLocalModelOverridesAcrossVersions(t *testing.T) { root := initRepository(t) baseline := variation("Baseline") @@ -733,7 +812,7 @@ func TestPromptRejectsDivergentChangesWithoutMutation(t *testing.T) { _, _, err := runPrompt(t, root, api, "--yes") - require.ErrorContains(t, err, "interactive conflict resolution requires a terminal") + require.ErrorContains(t, err, "requires --conflict or --resolve") requireOnlyReads(t, api.requests) } diff --git a/internal/sync/prompt/attach.go b/internal/sync/prompt/attach.go index aeea6c1f..5977f419 100644 --- a/internal/sync/prompt/attach.go +++ b/internal/sync/prompt/attach.go @@ -108,7 +108,7 @@ func selectAttachmentProject(resources []syncdomain.SyncedResource, options atta return projectKeys[0], nil } if !options.Interactive { - return "", fmt.Errorf("--project is required when the workspace manages multiple projects") + return "", fmt.Errorf("--to is required when the workspace manages multiple projects") } choices := make([]syncinteractive.Choice[string], 0, len(projectKeys)) @@ -128,7 +128,7 @@ func selectAttachment(client syncapi.Client, projectKey string, options attachOp return client.ReadAttachment(projectKey, options.Kind, options.Key) } if !options.Interactive { - return syncdomain.Attachment{}, fmt.Errorf("%s key is required without a terminal", options.Kind) + return syncdomain.Attachment{}, fmt.Errorf("%s key is required without interactive input", options.Kind) } attachment, canceled, err := syncinteractive.SearchSelect(syncinteractive.SearchOptions[syncdomain.Attachment]{ @@ -216,7 +216,7 @@ func selectManagedVariation( return "", syncdomain.Variation{}, fmt.Errorf("variation %q is not managed in project %q", options.VariationID, projectKey) } if !options.Interactive { - return "", syncdomain.Variation{}, fmt.Errorf("--variation is required without a terminal") + return "", syncdomain.Variation{}, fmt.Errorf("--to is required without interactive input") } if len(choices) == 0 { return "", syncdomain.Variation{}, fmt.Errorf("no eligible variations are managed in project %q", projectKey) diff --git a/internal/sync/prompt/conflict.go b/internal/sync/prompt/conflict.go index ebcf15bb..dc6f4d32 100644 --- a/internal/sync/prompt/conflict.go +++ b/internal/sync/prompt/conflict.go @@ -13,22 +13,45 @@ import ( syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive" ) -type conflictResolution string +// ConflictResolution identifies which side should win a sync conflict. +type ConflictResolution string const ( - useLaunchDarkly conflictResolution = "launchdarkly" - useLocal conflictResolution = "local" - abortConflict conflictResolution = "abort" + // ConflictUseLaunchDarkly applies the current LaunchDarkly resource locally. + ConflictUseLaunchDarkly ConflictResolution = "launchdarkly" + // ConflictUseLocal applies the current local resource to LaunchDarkly. + ConflictUseLocal ConflictResolution = "local" + // ConflictAbort stops before applying the conflicted plan. + ConflictAbort ConflictResolution = "abort" ) +// ConflictPolicy supplies a default resolution and per-variation exceptions. +type ConflictPolicy struct { + Default ConflictResolution + Overrides map[ResourceID]ConflictResolution +} + +// ParseConflictResolution validates a CLI conflict choice. +func ParseConflictResolution(value string) (ConflictResolution, error) { + resolution := ConflictResolution(value) + switch resolution { + case ConflictUseLaunchDarkly, ConflictUseLocal, ConflictAbort: + return resolution, nil + default: + return "", fmt.Errorf("invalid conflict resolution %q; expected launchdarkly, local, or abort", value) + } +} + type conflictResolutionResult struct { - resolutions map[ResourceID]conflictResolution + resolutions map[ResourceID]ConflictResolution aborted bool sourcesChanged bool } +var errConflictAborted = errors.New("sync conflict left unresolved") + type conflictChoice struct { - resolution conflictResolution + resolution ConflictResolution aborted bool sourcesChanged bool } @@ -50,13 +73,8 @@ func resolveConflicts( if len(conflicts) == 0 { return conflictResolutionResult{}, nil } - if !interactive { - return conflictResolutionResult{}, fmt.Errorf( - "interactive conflict resolution requires a terminal; rerun in a terminal or resolve the conflict manually", - ) - } - result := conflictResolutionResult{resolutions: make(map[ResourceID]conflictResolution)} + result := conflictResolutionResult{resolutions: make(map[ResourceID]ConflictResolution)} for _, group := range conflicts { visible := make([]PlannedResource, 0, len(group.resources)) for _, resource := range group.resources { @@ -79,10 +97,31 @@ func resolveConflicts( } } - choice, err := readConflictChoice(options, reader, watched) + resolution, explicit, err := options.ConflictPolicy.resolve(group) if err != nil { return conflictResolutionResult{}, err } + var choice conflictChoice + if explicit { + choice = conflictChoice{ + resolution: resolution, + aborted: resolution == ConflictAbort, + } + writeConflictChoice(options.ErrorOutput, choice) + if choice.aborted { + return conflictResolutionResult{}, errConflictAborted + } + } else { + if !interactive { + return conflictResolutionResult{}, fmt.Errorf( + "conflict resolution requires --conflict or --resolve without interactive input", + ) + } + choice, err = readConflictChoice(options, reader, watched) + if err != nil { + return conflictResolutionResult{}, err + } + } if choice.sourcesChanged { result.sourcesChanged = true return result, nil @@ -98,6 +137,31 @@ func resolveConflicts( return result, nil } +func (policy ConflictPolicy) resolve(group conflictGroup) (ConflictResolution, bool, error) { + var selected ConflictResolution + for _, resource := range group.resources { + resolution, ok := policy.Overrides[resource.ID] + if !ok { + continue + } + if _, err := ParseConflictResolution(string(resolution)); err != nil { + return "", false, err + } + if selected != "" && selected != resolution { + return "", false, fmt.Errorf("conflicting --resolve choices affect the same shared attachment") + } + selected = resolution + } + if selected != "" { + return selected, true, nil + } + if policy.Default == "" { + return "", false, nil + } + resolution, err := ParseConflictResolution(string(policy.Default)) + return resolution, err == nil, err +} + // groupConflicts presents one choice for each connected set of variations that // share a changed dependency, including conflicts with other local edits. func groupConflicts(plan Plan) []conflictGroup { @@ -157,10 +221,10 @@ func promptConflictResolution(ctx context.Context, input io.Reader, output io.Wr input, output, "Choose how to resolve this conflict", - []syncinteractive.Choice[conflictResolution]{ - {Title: "Use LaunchDarkly", Value: useLaunchDarkly}, - {Title: "Use local", Value: useLocal}, - {Title: "Abort and resolve manually", Value: abortConflict}, + []syncinteractive.Choice[ConflictResolution]{ + {Title: "Use LaunchDarkly", Value: ConflictUseLaunchDarkly}, + {Title: "Use local", Value: ConflictUseLocal}, + {Title: "Abort and resolve manually", Value: ConflictAbort}, }, ) if err != nil { @@ -168,7 +232,7 @@ func promptConflictResolution(ctx context.Context, input io.Reader, output io.Wr } choice := conflictChoice{ resolution: resolution, - aborted: canceled || resolution == abortConflict, + aborted: canceled || resolution == ConflictAbort, } writeConflictChoice(output, choice) return choice, nil @@ -221,9 +285,9 @@ func writeConflictChoice(output io.Writer, choice conflictChoice) { switch { case choice.aborted: _ = console.Line("Sync canceled; conflict left unresolved.") - case choice.resolution == useLaunchDarkly: + case choice.resolution == ConflictUseLaunchDarkly: _ = console.Line("Using LaunchDarkly.") - case choice.resolution == useLocal: + case choice.resolution == ConflictUseLocal: _ = console.Line("Using local.") } } @@ -253,7 +317,7 @@ func (watched watchedSources) WaitForChange(ctx context.Context) error { // applyConflictResolutions applies one direction to every resource in each // conflict group, including non-conflicted consumers of a shared attachment. -func applyConflictResolutions(plan Plan, resolutions map[ResourceID]conflictResolution) Plan { +func applyConflictResolutions(plan Plan, resolutions map[ResourceID]ConflictResolution) Plan { // Clone the resource slice before changing actions so the reviewed plan // remains an immutable record for post-review revalidation. resolved := Plan{Resources: append([]PlannedResource(nil), plan.Resources...)} @@ -269,14 +333,14 @@ func applyConflictResolutions(plan Plan, resolutions map[ResourceID]conflictReso } // resolvedConflictAction returns the operation needed for the chosen side to win. -func resolvedConflictAction(resource PlannedResource, resolution conflictResolution) Action { +func resolvedConflictAction(resource PlannedResource, resolution ConflictResolution) Action { switch resolution { - case useLaunchDarkly: + case ConflictUseLaunchDarkly: if resource.Server == nil { return ActionDeleteLocal } return ActionUpdateLocal - case useLocal: + case ConflictUseLocal: if resource.Local == nil { return ActionArchiveServer } diff --git a/internal/sync/prompt/conflict_test.go b/internal/sync/prompt/conflict_test.go index 8c2b1460..0fa38cf5 100644 --- a/internal/sync/prompt/conflict_test.go +++ b/internal/sync/prompt/conflict_test.go @@ -24,15 +24,15 @@ func TestResolvedConflictAction(t *testing.T) { tests := []struct { name string resource PlannedResource - resolution conflictResolution + resolution ConflictResolution expected Action }{ - {"LaunchDarkly updates an existing local resource", PlannedResource{Local: &local, Server: &server}, useLaunchDarkly, ActionUpdateLocal}, - {"LaunchDarkly restores a missing local resource", PlannedResource{Server: &server}, useLaunchDarkly, ActionUpdateLocal}, - {"LaunchDarkly deletion removes the local resource", PlannedResource{Local: &local}, useLaunchDarkly, ActionDeleteLocal}, - {"local updates an existing server resource", PlannedResource{Local: &local, Server: &server}, useLocal, ActionUpdateServer}, - {"local creates a missing server resource", PlannedResource{Local: &local}, useLocal, ActionCreateServer}, - {"local deletion archives the server resource", PlannedResource{Server: &server}, useLocal, ActionArchiveServer}, + {"LaunchDarkly updates an existing local resource", PlannedResource{Local: &local, Server: &server}, ConflictUseLaunchDarkly, ActionUpdateLocal}, + {"LaunchDarkly restores a missing local resource", PlannedResource{Server: &server}, ConflictUseLaunchDarkly, ActionUpdateLocal}, + {"LaunchDarkly deletion removes the local resource", PlannedResource{Local: &local}, ConflictUseLaunchDarkly, ActionDeleteLocal}, + {"local updates an existing server resource", PlannedResource{Local: &local, Server: &server}, ConflictUseLocal, ActionUpdateServer}, + {"local creates a missing server resource", PlannedResource{Local: &local}, ConflictUseLocal, ActionCreateServer}, + {"local deletion archives the server resource", PlannedResource{Server: &server}, ConflictUseLocal, ActionArchiveServer}, } for _, test := range tests { @@ -52,7 +52,7 @@ func TestApplyConflictResolutionsDoesNotChangeReviewedPlan(t *testing.T) { {ID: sharedID, Action: ActionUpdateServer, Local: &local, Server: &server}, }} - resolved := applyConflictResolutions(reviewed, map[ResourceID]conflictResolution{id: useLaunchDarkly, sharedID: useLaunchDarkly}) + resolved := applyConflictResolutions(reviewed, map[ResourceID]ConflictResolution{id: ConflictUseLaunchDarkly, sharedID: ConflictUseLaunchDarkly}) assert.Equal(t, ActionConflict, reviewed.Resources[0].Action) assert.Equal(t, ActionUpdateServer, reviewed.Resources[1].Action) @@ -118,7 +118,7 @@ func TestApplyLocalChangeRestoresMissingConflictFile(t *testing.T) { func TestWriteConflictChoice(t *testing.T) { var output bytes.Buffer - writeConflictChoice(&output, conflictChoice{resolution: useLocal}) + writeConflictChoice(&output, conflictChoice{resolution: ConflictUseLocal}) writeConflictChoice(&output, conflictChoice{aborted: true}) assert.Equal(t, "Using local.\nSync canceled; conflict left unresolved.\n", output.String()) } @@ -133,7 +133,7 @@ func TestResolveConflictsShowsDiffBeforePrompt(t *testing.T) { require.NoError(t, err) assert.False(t, result.aborted) - assert.Equal(t, useLocal, result.resolutions[testResourceID()]) + assert.Equal(t, ConflictUseLocal, result.resolutions[testResourceID()]) rendered := output.String() assert.Contains(t, rendered, "LaunchDarkly now") assert.Contains(t, rendered, "Local file now") @@ -146,7 +146,48 @@ func TestResolveConflictsRequiresTerminalEvenWithYes(t *testing.T) { _, err := resolveConflicts(Options{Input: input, ErrorOutput: &bytes.Buffer{}, Yes: true}, plan, input, false, nil) - require.ErrorContains(t, err, "interactive conflict resolution requires a terminal") + require.ErrorContains(t, err, "requires --conflict or --resolve") +} + +func TestResolveConflictsUsesNonInteractivePolicy(t *testing.T) { + plan := divergentPlan(t) + id := testResourceID() + options := Options{ + ConflictPolicy: ConflictPolicy{ + Default: ConflictUseLaunchDarkly, + Overrides: map[ResourceID]ConflictResolution{id: ConflictUseLocal}, + }, + ErrorOutput: io.Discard, + } + + result, err := resolveConflicts(options, plan, strings.NewReader(""), false, nil) + + require.NoError(t, err) + assert.Equal(t, ConflictUseLocal, result.resolutions[id]) +} + +func TestResolveConflictsReturnsErrorForExplicitAbort(t *testing.T) { + plan := divergentPlan(t) + options := Options{ + ConflictPolicy: ConflictPolicy{Default: ConflictAbort}, + ErrorOutput: io.Discard, + } + + _, err := resolveConflicts(options, plan, strings.NewReader(""), false, nil) + + require.ErrorContains(t, err, "sync conflict left unresolved") +} + +func TestConflictPolicyRejectsContradictorySharedAttachmentChoices(t *testing.T) { + first, second := testResourceID(), testResourceID() + second.LookupKey = "config/second" + policy := ConflictPolicy{Overrides: map[ResourceID]ConflictResolution{ + first: ConflictUseLocal, second: ConflictUseLaunchDarkly, + }} + + _, _, err := policy.resolve(conflictGroup{resources: []PlannedResource{{ID: first}, {ID: second}}}) + + require.ErrorContains(t, err, "same shared attachment") } func TestGroupConflictsDeduplicatesSharedAttachment(t *testing.T) { diff --git a/internal/sync/prompt/runner.go b/internal/sync/prompt/runner.go index ee0002f3..cae02421 100644 --- a/internal/sync/prompt/runner.go +++ b/internal/sync/prompt/runner.go @@ -26,34 +26,80 @@ import ( syncsource "github.com/launchdarkly/ldcli/internal/sync/source" ) -// AttachmentRequest identifies one tool or skill to attach to a managed variation. -type AttachmentRequest struct { - Kind syncdomain.AttachmentKind - Key string - ProjectKey string - Variation string +// CommandAction is one focused prompt sync operation. +type CommandAction interface { + promptSyncAction() } +// SyncAction reconciles the workspace once or whenever watched files change. +type SyncAction struct { + Watch bool + DryRun bool +} + +// AddAction materializes selected LaunchDarkly variations as local files. +type AddAction struct { + Variations []syncdomain.ResourceID + DryRun bool +} + +// AttachAction adds one tool or skill reference to a managed variation. +type AttachAction struct { + Kind syncdomain.AttachmentKind + Key string + Target *syncdomain.ResourceID +} + +// DetachAction stops managing selected local variations. +type DetachAction struct { + Variations []syncdomain.ResourceID +} + +// LinkAction creates a managed variation backed by an external prompt file. +type LinkAction struct { + File string + Format string + Target *synclink.Target +} + +func (SyncAction) promptSyncAction() {} +func (AddAction) promptSyncAction() {} +func (AttachAction) promptSyncAction() {} +func (DetachAction) promptSyncAction() {} +func (LinkAction) promptSyncAction() {} + // Options contains command input and streams for one prompt synchronization. type Options struct { WorkingDirectory string AccessToken string BaseURI string OutputKind string - Add bool - Attachment *AttachmentRequest - Detach bool - DryRun bool - Format string - Link string - Watch bool + Action CommandAction + ConflictPolicy ConflictPolicy Yes bool + NoInput bool Context context.Context Input io.Reader Output io.Writer ErrorOutput io.Writer } +func (options Options) dryRun() bool { + switch action := options.Action.(type) { + case SyncAction: + return action.DryRun + case AddAction: + return action.DryRun + default: + return false + } +} + +func (options Options) watching() bool { + action, ok := options.Action.(SyncAction) + return ok && action.Watch +} + type bootstrapRunner func(syncbootstrap.Options) error type detachRunner func(syncdetach.Options) error type linkRunner func(synclink.Options) (string, error) @@ -100,6 +146,9 @@ func (runner Runner) Run(options Options) error { if err := validateOptions(options); err != nil { return err } + if options.Action == nil { + options.Action = SyncAction{} + } // Every path stored in wrappers or the manifest is repository-relative, so // resolve the canonical Git root before dispatching any command mode. resolvedWorkspace, err := syncsource.NewResolver().Resolve(options.WorkingDirectory) @@ -112,8 +161,13 @@ func (runner Runner) Run(options Options) error { local: synclocal.NewStore(resolvedWorkspace.Root), manifest: syncmanifest.NewStore(apiClient, resolvedWorkspace.Source), } + localDirectoryExists, err := workspace.local.Exists() + if err != nil { + return err + } - if options.Detach { + switch action := options.Action.(type) { + case DetachAction: projectKeys, err := discoverProjectKeys(workspace.root) if err != nil { return err @@ -125,18 +179,21 @@ func (runner Runner) Run(options Options) error { ProjectKeys: projectKeys, Input: options.Input, Output: options.Output, + Selections: action.Variations, + NoInput: options.NoInput, }) - } - if options.Link != "" { + case LinkAction: path, err := runner.link(synclink.Options{ Catalog: apiClient, Store: workspace.local, RepositoryRoot: workspace.root, WorkingDirectory: options.WorkingDirectory, - File: options.Link, - Format: options.Format, + File: action.File, + Format: action.Format, Input: options.Input, Output: options.Output, + Target: action.Target, + NoInput: options.NoInput, }) if err != nil { return err @@ -149,23 +206,23 @@ func (runner Runner) Run(options Options) error { syncdomain.RootDir, path, ) - } - localDirectoryExists, err := workspace.local.Exists() - if err != nil { - return err - } - - if options.Attachment != nil { + localDirectoryExists = true + case AttachAction: if !localDirectoryExists { return fmt.Errorf("attach a tool or skill after synchronizing at least one variation") } + projectKey, variationID := "", "" + if action.Target != nil { + projectKey = action.Target.ProjectKey + variationID = action.Target.LookupKey + } if err := attachToVariation(workspace.local, apiClient, attachOptions{ RepositoryRoot: workspace.root, - ProjectKey: options.Attachment.ProjectKey, - VariationID: options.Attachment.Variation, - Kind: options.Attachment.Kind, - Key: options.Attachment.Key, - Interactive: runner.isTerminal(options.Input, options.Output), + ProjectKey: projectKey, + VariationID: variationID, + Kind: action.Kind, + Key: action.Key, + Interactive: !options.NoInput && runner.isTerminal(options.Input, options.Output), Input: options.Input, Output: options.Output, }); err != nil { @@ -174,13 +231,30 @@ func (runner Runner) Run(options Options) error { } return err } + case AddAction: + return runner.bootstrap(syncbootstrap.Options{ + Catalog: apiClient, + Attachments: apiClient, + Store: workspace.local, + Manifest: workspace.manifest, + Input: options.Input, + Output: options.Output, + Initial: !localDirectoryExists, + DryRun: action.DryRun, + Selections: action.Variations, + NoInput: options.NoInput, + }) + case SyncAction: + default: + return fmt.Errorf("unsupported prompt sync action %T", action) } projectKeys, err := discoverProjectKeys(workspace.root) if err != nil { return err } - if (!localDirectoryExists && len(projectKeys) == 0) || options.Add { + syncAction, isSync := options.Action.(SyncAction) + if !localDirectoryExists && len(projectKeys) == 0 { if err := runner.bootstrap(syncbootstrap.Options{ Catalog: apiClient, Attachments: apiClient, @@ -189,11 +263,12 @@ func (runner Runner) Run(options Options) error { Input: options.Input, Output: options.Output, Initial: !localDirectoryExists, - DryRun: options.DryRun, + DryRun: isSync && syncAction.DryRun, + NoInput: options.NoInput, }); err != nil { return err } - if !options.Watch { + if !isSync || !syncAction.Watch { return nil } localDirectoryExists, err = workspace.local.Exists() @@ -205,7 +280,7 @@ func (runner Runner) Run(options Options) error { } } - if options.Watch { + if isSync && syncAction.Watch { ctx := options.Context if ctx == nil { ctx = context.Background() @@ -213,28 +288,15 @@ func (runner Runner) Run(options Options) error { ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer stop() - syncOptions := optionsForWatchSync(ctx, options) - // Watch owns the retry loop. Each callback still runs the exact same - // plan, review, revalidation, and execution pipeline as a normal sync. + options.Context = ctx return runner.watch(ctx, workspace.root, watchDebounce, func(watcher *sourceWatcher) error { - return runner.runWorkspaceSync(syncOptions, workspace, watcher) + return runner.runWorkspaceSync(options, workspace, watcher) }, options.ErrorOutput) } return runner.runWorkspaceSync(options, workspace, nil) } -// optionsForWatchSync clears one-time actions while preserving explicit user -// choices such as --yes for each sync triggered by the watcher. -func optionsForWatchSync(ctx context.Context, options Options) Options { - options.Add = false - options.Attachment = nil - options.Format = "" - options.Link = "" - options.Context = ctx - return options -} - // runWorkspaceSync plans, reviews, revalidates, and executes one workspace sync. func (runner Runner) runWorkspaceSync(options Options, workspace syncWorkspace, watcher *sourceWatcher) error { var watched *watchedSources @@ -266,14 +328,14 @@ func (runner Runner) runWorkspaceSync(options Options, workspace syncWorkspace, return err } - if options.DryRun { + if options.dryRun() { if err := writePlanOutput(options.Output, options.OutputKind, reviewedPlan); err != nil { return err } return reviewedPlan.BlockingError() } - interactive := runner.isTerminal(options.Input, options.ErrorOutput) + interactive := !options.NoInput && runner.isTerminal(options.Input, options.ErrorOutput) conflictResult, err := resolveConflicts(options, reviewedPlan, options.Input, interactive, watched) if err != nil { return err @@ -303,7 +365,7 @@ func (runner Runner) runWorkspaceSync(options Options, workspace syncWorkspace, return err } if !slices.Equal(reviewedProjectKeys, currentProjectKeys) { - if options.Watch { + if options.watching() { return errRefreshWatchPlan } return fmt.Errorf("sync projects changed after review; run sync again") @@ -317,7 +379,7 @@ func (runner Runner) runWorkspaceSync(options Options, workspace syncWorkspace, return err } if !samePlanState(reviewedPlan, currentPlan) { - if options.Watch { + if options.watching() { return errRefreshWatchPlan } return fmt.Errorf("sync state changed after review; run sync again") @@ -351,27 +413,36 @@ func (runner Runner) runWorkspaceSync(options Options, workspace syncWorkspace, // validateOptions rejects command modes whose side effects or UX conflict. func validateOptions(options Options) error { - switch { - case options.Attachment != nil && - options.Attachment.Kind != syncdomain.AttachmentTool && - options.Attachment.Kind != syncdomain.AttachmentSkill: - return fmt.Errorf("attachment kind must be tool or skill") - case options.Attachment != nil && - (options.Add || options.Detach || options.DryRun || options.Link != "" || options.Format != "" || options.Watch): - return fmt.Errorf("attachment options cannot be combined with other sync actions") - case options.Detach && (options.Add || options.DryRun || options.Link != "" || options.Format != "" || options.Watch || options.Yes): - return fmt.Errorf("--detach cannot be combined with other sync actions") - case options.Link == "" && options.Format != "": - return fmt.Errorf("--format requires --link") - case options.Link != "" && options.Format == "": - return fmt.Errorf("--link requires --format") - case options.Link != "" && (options.Add || options.DryRun): - return fmt.Errorf("--link cannot be used with --add or --dry-run") - case options.Watch && options.DryRun: - return fmt.Errorf("--watch cannot be used with --dry-run") - } - if options.Link != "" { - return syncreference.ValidateFormat(options.Format) + switch action := options.Action.(type) { + case nil: + case SyncAction: + if action.Watch && action.DryRun { + return fmt.Errorf("watch does not support --dry-run") + } + case AddAction: + case AttachAction: + if action.Kind != syncdomain.AttachmentTool && action.Kind != syncdomain.AttachmentSkill { + return fmt.Errorf("attachment kind must be tool or skill") + } + if action.Target != nil && action.Target.Kind != syncdomain.KindVariation { + return fmt.Errorf("attachment target must be a variation") + } + case DetachAction: + case LinkAction: + if action.File == "" { + return fmt.Errorf("linked file is required") + } + if action.Format == "" { + return fmt.Errorf("--format is required") + } + if err := syncreference.ValidateFormat(action.Format); err != nil { + return err + } + if action.Target != nil && action.Target.ModelConfigKey == "" { + return fmt.Errorf("--model-config-key is required with --to") + } + default: + return fmt.Errorf("unsupported prompt sync action %T", action) } return nil } diff --git a/internal/sync/prompt/runner_test.go b/internal/sync/prompt/runner_test.go index 8c93fea0..61034660 100644 --- a/internal/sync/prompt/runner_test.go +++ b/internal/sync/prompt/runner_test.go @@ -28,20 +28,22 @@ import ( func TestRunnerBootstrapsMissingWorkspaceAndAddsToExistingWorkspace(t *testing.T) { tests := map[string]struct { createDirectory bool - add bool - dryRun bool + action CommandAction wantInitial bool + wantDryRun bool }{ "missing workspace": { + action: SyncAction{}, wantInitial: true, }, "missing workspace dry run": { - dryRun: true, + action: SyncAction{DryRun: true}, wantInitial: true, + wantDryRun: true, }, "add to existing workspace": { createDirectory: true, - add: true, + action: AddAction{}, }, } @@ -57,7 +59,7 @@ func TestRunnerBootstrapsMissingWorkspaceAndAddsToExistingWorkspace(t *testing.T runner.bootstrap = func(options syncbootstrap.Options) error { called = true assert.Equal(t, test.wantInitial, options.Initial) - assert.Equal(t, test.dryRun, options.DryRun) + assert.Equal(t, test.wantDryRun, options.DryRun) assert.NotNil(t, options.Catalog) assert.NotNil(t, options.Input) assert.NotNil(t, options.Output) @@ -68,8 +70,7 @@ func TestRunnerBootstrapsMissingWorkspaceAndAddsToExistingWorkspace(t *testing.T WorkingDirectory: root, AccessToken: "token", BaseURI: "https://example.com", - Add: test.add, - DryRun: test.dryRun, + Action: test.action, Input: os.Stdin, Output: io.Discard, ErrorOutput: io.Discard, @@ -121,7 +122,7 @@ func TestRunnerWatchDoesNotRunInitialSync(t *testing.T) { err := runner.Run(Options{ WorkingDirectory: root, - Watch: true, + Action: SyncAction{Watch: true}, Input: os.Stdin, Output: io.Discard, ErrorOutput: io.Discard, @@ -131,22 +132,7 @@ func TestRunnerWatchDoesNotRunInitialSync(t *testing.T) { assert.True(t, watchCalled) } -func TestOptionsForWatchSyncPreservesYes(t *testing.T) { - ctx := context.Background() - for _, yes := range []bool{false, true} { - options := optionsForWatchSync(ctx, Options{ - Add: true, Format: syncreference.PlainMarkdown, Link: "prompt.md", Yes: yes, - }) - - assert.False(t, options.Add) - assert.Empty(t, options.Format) - assert.Empty(t, options.Link) - assert.Equal(t, yes, options.Yes) - assert.Equal(t, ctx, options.Context) - } -} - -func TestRunnerLinksBeforeWatching(t *testing.T) { +func TestRunnerLinksPrompt(t *testing.T) { root := initGitRepository(t) runner := NewRunner(noopResourceClient{}) linkCalled := false @@ -154,28 +140,21 @@ func TestRunnerLinksBeforeWatching(t *testing.T) { linkCalled = true assert.Equal(t, "prompt.md", options.File) assert.Equal(t, syncreference.PlainMarkdown, options.Format) - require.NoError(t, os.Mkdir(filepath.Join(root, syncdomain.RootDir), 0o755)) - return "project/configs/config/prompt.prompt.md", nil - } - watchCalled := false - runner.watch = func(context.Context, string, time.Duration, func(*sourceWatcher) error, io.Writer) error { - watchCalled = true - return nil + return "", nil } err := runner.Run(Options{ WorkingDirectory: root, - Link: "prompt.md", - Format: syncreference.PlainMarkdown, - Watch: true, - Input: os.Stdin, - Output: io.Discard, - ErrorOutput: io.Discard, + Action: LinkAction{ + File: "prompt.md", Format: syncreference.PlainMarkdown, + }, + Input: os.Stdin, + Output: io.Discard, + ErrorOutput: io.Discard, }) require.NoError(t, err) assert.True(t, linkCalled) - assert.True(t, watchCalled) } func TestRunnerDetachesWithoutCallingTheAPI(t *testing.T) { @@ -193,7 +172,7 @@ func TestRunnerDetachesWithoutCallingTheAPI(t *testing.T) { err := runner.Run(Options{ WorkingDirectory: root, - Detach: true, + Action: DetachAction{}, Input: os.Stdin, Output: io.Discard, ErrorOutput: io.Discard, @@ -219,7 +198,7 @@ func TestRunnerChecksAttachmentTerminalOnOutput(t *testing.T) { err := runner.Run(Options{ WorkingDirectory: root, - Attachment: &AttachmentRequest{Kind: syncdomain.AttachmentTool}, + Action: AttachAction{Kind: syncdomain.AttachmentTool}, Input: input, Output: &output, ErrorOutput: &errorOutput, @@ -231,21 +210,16 @@ func TestRunnerChecksAttachmentTerminalOnOutput(t *testing.T) { } func TestValidateOptions(t *testing.T) { - require.ErrorContains(t, validateOptions(Options{Format: syncreference.PlainMarkdown}), "--format requires --link") - require.ErrorContains(t, validateOptions(Options{Link: "prompt.md"}), "--link requires --format") - require.ErrorContains(t, validateOptions(Options{Watch: true, DryRun: true}), "--watch cannot be used with --dry-run") - require.ErrorContains(t, validateOptions(Options{Detach: true, Add: true}), "--detach cannot be combined") require.ErrorContains(t, validateOptions(Options{ - Attachment: &AttachmentRequest{Kind: syncdomain.AttachmentTool}, Watch: true, - }), "attachment options cannot be combined") + Action: LinkAction{File: "prompt.md"}, + }), "--format is required") require.ErrorContains(t, validateOptions(Options{ - Attachment: &AttachmentRequest{Kind: "invalid"}, - }), "attachment kind") + Action: SyncAction{Watch: true, DryRun: true}, + }), "watch does not support --dry-run") + require.ErrorContains(t, validateOptions(Options{Action: AttachAction{Kind: "invalid"}}), "attachment kind") require.NoError(t, validateOptions(Options{ - Attachment: &AttachmentRequest{ - Kind: syncdomain.AttachmentTool, Key: "search", ProjectKey: "project", Variation: "config/default", - }, - Yes: true, + Action: AttachAction{Kind: syncdomain.AttachmentTool, Key: "search"}, + Yes: true, })) } diff --git a/internal/sync/prompt/terminal.go b/internal/sync/prompt/terminal.go index 9fda9fa1..064c73c0 100644 --- a/internal/sync/prompt/terminal.go +++ b/internal/sync/prompt/terminal.go @@ -25,7 +25,7 @@ func reviewAndConfirmPlan(options Options, plan Plan, interactive bool) (bool, e } return false, nil } - autoApply := options.Yes || (options.Watch && !plan.HasDestructiveActions()) + autoApply := options.Yes || (options.watching() && !plan.HasDestructiveActions()) if autoApply || !plan.RequiresConfirmation() { return true, nil } diff --git a/internal/sync/prompt/terminal_test.go b/internal/sync/prompt/terminal_test.go index 414494a3..adead02f 100644 --- a/internal/sync/prompt/terminal_test.go +++ b/internal/sync/prompt/terminal_test.go @@ -172,7 +172,7 @@ func TestReviewAndConfirmPlanStopsWhenWatchContextIsCanceled(t *testing.T) { }, 1) go func() { confirmed, err := reviewAndConfirmPlan(Options{ - Watch: true, Context: ctx, Input: input, ErrorOutput: output, + Action: SyncAction{Watch: true}, Context: ctx, Input: input, ErrorOutput: output, }, Plan{Resources: []PlannedResource{{ ID: testResourceID(), Action: ActionArchiveServer, }}}, true) @@ -206,7 +206,7 @@ func TestReviewAndConfirmPlanRejectsConfirmationWhenWatchContextIsAlreadyCancele cancel() confirmed, err := reviewAndConfirmPlan(Options{ - Watch: true, Context: ctx, Input: strings.NewReader("yes\n"), ErrorOutput: io.Discard, + Action: SyncAction{Watch: true}, Context: ctx, Input: strings.NewReader("yes\n"), ErrorOutput: io.Discard, }, Plan{Resources: []PlannedResource{{ ID: testResourceID(), Action: ActionArchiveServer, }}}, true) @@ -250,7 +250,7 @@ func TestReviewAndConfirmPlanWatchPolicy(t *testing.T) { var output bytes.Buffer plan := Plan{Resources: []PlannedResource{{ID: testResourceID(), Action: test.action}}} continued, err := reviewAndConfirmPlan(Options{ - Watch: true, Yes: test.yes, Input: strings.NewReader(test.input), ErrorOutput: &output, + Action: SyncAction{Watch: true}, Yes: test.yes, Input: strings.NewReader(test.input), ErrorOutput: &output, }, plan, test.interactive) assert.Equal(t, test.wantContinue, continued) diff --git a/internal/sync/prompt/watch.go b/internal/sync/prompt/watch.go index b9243f84..093cdfd6 100644 --- a/internal/sync/prompt/watch.go +++ b/internal/sync/prompt/watch.go @@ -84,6 +84,9 @@ func watchWorkspace( if errors.Is(err, context.Canceled) { return nil } + if errors.Is(err, errConflictAborted) { + return err + } if errors.Is(err, errRefreshWatchPlan) { // A source changed during review or the reviewed server state // became stale. Rebuild immediately instead of waiting for a diff --git a/internal/sync/prompt/watch_test.go b/internal/sync/prompt/watch_test.go index 1da1dc21..7b4ea549 100644 --- a/internal/sync/prompt/watch_test.go +++ b/internal/sync/prompt/watch_test.go @@ -100,6 +100,31 @@ func TestWatchWorkspaceKeepsWatchingAfterSyncError(t *testing.T) { require.NoError(t, <-done) } +func TestWatchWorkspaceStopsAfterExplicitConflictAbort(t *testing.T) { + root := t.TempDir() + wrapper := filepath.Join(root, syncdomain.RootDir, "project", "configs", "config", "prompt.prompt.md") + require.NoError(t, os.MkdirAll(filepath.Dir(wrapper), 0o755)) + require.NoError(t, os.WriteFile(wrapper, []byte("initial"), 0o644)) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + done <- watchWorkspace(ctx, root, 15*time.Millisecond, func(*sourceWatcher) error { + return errConflictAborted + }, io.Discard) + }() + + time.Sleep(30 * time.Millisecond) + require.NoError(t, os.WriteFile(wrapper, []byte("changed"), 0o644)) + select { + case err := <-done: + require.ErrorIs(t, err, errConflictAborted) + case <-time.After(time.Second): + t.Fatal("watch did not stop after conflict abort") + } +} + func TestWatchWorkspaceRefreshesPlanAfterSourceChangesDuringSync(t *testing.T) { root := t.TempDir() wrapper := filepath.Join(root, syncdomain.RootDir, "project", "configs", "config", "prompt.prompt.md") diff --git a/internal/sync/resource.go b/internal/sync/resource.go index b646ede2..cb941278 100644 --- a/internal/sync/resource.go +++ b/internal/sync/resource.go @@ -26,6 +26,28 @@ type ResourceID struct { LookupKey string } +// ParseVariationSelector parses the stable project-key/config-key/variation-key identity +// accepted by non-interactive sync commands. +func ParseVariationSelector(selector string) (ResourceID, error) { + parts := strings.Split(selector, "/") + if len(parts) != 3 || slices.ContainsFunc(parts, func(part string) bool { + return part == "" || + part == "." || + part == ".." || + strings.ContainsAny(part, "\\\x00") + }) { + return ResourceID{}, fmt.Errorf( + "invalid variation %q; expected project-key/config-key/variation-key", + selector, + ) + } + return ResourceID{ + Kind: KindVariation, + ProjectKey: parts[0], + LookupKey: parts[1] + "/" + parts[2], + }, nil +} + // CompareResourceIDs orders resource identities for deterministic plans and output. func CompareResourceIDs(left, right ResourceID) int { if result := strings.Compare(string(left.Kind), string(right.Kind)); result != 0 { From 8479c7062482e3fd326d31ebf85fbeccfb1e5279 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Wed, 7 Oct 2026 23:11:24 -0400 Subject: [PATCH 2/4] refactor(sync): simplify and reorganize the sync packages Put each shared rule in the domain package once. The rules are key validation, variation validation, lookup keys, attachment loading, version pins, and attachment comparison. A compiled local variation now holds a typed Variation instead of a JSON payload. Split the api, local, and prompt packages into files by concern. The api client uses one read helper and one write helper. The local package owns the directory layout in one file, and its writes share one staging path. The prompt package has one file for each step of a sync. Fix the manifest store. If a patch failed without a response but LaunchDarkly applied it, Update returned an empty manifest for the project. Add a golden test that pins the fingerprints that remote manifests store. --- internal/sync/api/attachment.go | 216 ++---- internal/sync/api/catalog.go | 259 +++---- internal/sync/api/client.go | 491 ++----------- internal/sync/api/errors.go | 94 +++ internal/sync/api/manifest.go | 92 +++ internal/sync/api/variation.go | 140 ++++ internal/sync/attachment.go | 111 +++ internal/sync/bootstrap/bootstrap.go | 332 +++------ internal/sync/bootstrap/bootstrap_test.go | 8 +- internal/sync/detach/detach.go | 203 +++--- internal/sync/detach/detach_test.go | 22 +- internal/sync/fingerprint.go | 175 ++--- internal/sync/fingerprint_golden_test.go | 52 ++ internal/sync/fingerprint_test.go | 10 +- internal/sync/interactive/catalog.go | 70 ++ internal/sync/interactive/terminal.go | 19 +- internal/sync/link/link.go | 393 ++++------ internal/sync/link/link_test.go | 22 +- internal/sync/local/attachment.go | 382 ++++------ internal/sync/local/attachment_test.go | 25 +- internal/sync/local/compile.go | 120 ++-- internal/sync/local/compile_test.go | 4 +- internal/sync/local/completion.go | 101 +++ internal/sync/local/create.go | 192 +++++ internal/sync/local/delete.go | 167 ++--- internal/sync/local/layout.go | 104 +++ internal/sync/local/reference.go | 154 ++-- internal/sync/local/reference_test.go | 7 +- internal/sync/local/render.go | 250 ------- internal/sync/local/replace.go | 460 ++++++------ internal/sync/local/sources.go | 72 ++ internal/sync/local/store.go | 240 +++---- internal/sync/local/store_test.go | 31 +- internal/sync/local/variation.go | 374 ++++------ internal/sync/local/variation_test.go | 49 +- internal/sync/manifest/model.go | 149 ++-- internal/sync/manifest/model_test.go | 10 +- internal/sync/manifest/store.go | 264 +++---- internal/sync/manifest/store_test.go | 51 ++ internal/sync/prompt/acceptance_test.go | 15 +- internal/sync/prompt/action.go | 135 ++++ internal/sync/prompt/attach.go | 255 +++---- internal/sync/prompt/attachment_versions.go | 95 +++ internal/sync/prompt/conflict.go | 309 ++++---- internal/sync/prompt/conflict_test.go | 26 +- internal/sync/prompt/diff.go | 662 +++-------------- internal/sync/prompt/execute.go | 482 ++++--------- internal/sync/prompt/execute_test.go | 7 +- internal/sync/prompt/local_changes.go | 36 - internal/sync/prompt/model_config.go | 111 ++- internal/sync/prompt/model_config_test.go | 17 +- internal/sync/prompt/output.go | 184 +++-- internal/sync/prompt/plan.go | 424 ++++------- internal/sync/prompt/plan_test.go | 4 +- internal/sync/prompt/render_diff.go | 602 ++++++++++++++++ internal/sync/prompt/review.go | 165 +++++ .../{terminal_test.go => review_test.go} | 2 +- internal/sync/prompt/runner.go | 671 ++++-------------- internal/sync/prompt/runner_test.go | 8 +- internal/sync/prompt/state.go | 191 +++++ internal/sync/prompt/sync.go | 121 ++++ internal/sync/prompt/terminal.go | 186 ----- internal/sync/prompt/watch.go | 48 +- internal/sync/reference/adapters/adapter.go | 47 +- .../plainmarkdown.go} | 28 +- .../plainmarkdown_test.go} | 13 +- internal/sync/reference/format.go | 79 ++- internal/sync/reference/format_test.go | 7 +- internal/sync/repository/git.go | 5 +- internal/sync/resource.go | 259 ++----- internal/sync/variation.go | 221 ++++++ 71 files changed, 5394 insertions(+), 5936 deletions(-) create mode 100644 internal/sync/api/errors.go create mode 100644 internal/sync/api/manifest.go create mode 100644 internal/sync/api/variation.go create mode 100644 internal/sync/attachment.go create mode 100644 internal/sync/fingerprint_golden_test.go create mode 100644 internal/sync/interactive/catalog.go create mode 100644 internal/sync/local/completion.go create mode 100644 internal/sync/local/create.go create mode 100644 internal/sync/local/layout.go delete mode 100644 internal/sync/local/render.go create mode 100644 internal/sync/local/sources.go create mode 100644 internal/sync/prompt/action.go create mode 100644 internal/sync/prompt/attachment_versions.go delete mode 100644 internal/sync/prompt/local_changes.go create mode 100644 internal/sync/prompt/render_diff.go create mode 100644 internal/sync/prompt/review.go rename internal/sync/prompt/{terminal_test.go => review_test.go} (98%) create mode 100644 internal/sync/prompt/state.go create mode 100644 internal/sync/prompt/sync.go delete mode 100644 internal/sync/prompt/terminal.go rename internal/sync/reference/adapters/{plain_markdown/plain_markdown.go => plainmarkdown/plainmarkdown.go} (52%) rename internal/sync/reference/adapters/{plain_markdown/plain_markdown_test.go => plainmarkdown/plainmarkdown_test.go} (75%) create mode 100644 internal/sync/variation.go diff --git a/internal/sync/api/attachment.go b/internal/sync/api/attachment.go index 7f93590b..330d6eae 100644 --- a/internal/sync/api/attachment.go +++ b/internal/sync/api/attachment.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" "strconv" syncdomain "github.com/launchdarkly/ldcli/internal/sync" @@ -20,6 +19,8 @@ type skillResponse struct { Version int `json:"version"` } +// The request bodies send every field that sync owns, so that an empty local +// value also clears the value in LaunchDarkly. type toolMutationRequest struct { Key string `json:"key,omitempty"` Description *string `json:"description"` @@ -33,85 +34,60 @@ type skillMutationRequest struct { Markdown string `json:"markdown"` } -// SearchAttachments returns one API-filtered page of latest attachments. +// SearchAttachments returns one page of the latest tools or skills that match query. func (client Client) SearchAttachments( projectKey string, kind syncdomain.AttachmentKind, query string, limit, offset int, ) (Page[syncdomain.Attachment], error) { - endpoint, err := client.attachmentEndpoint(projectKey, kind, "") + path, err := attachmentPath(projectKey, kind) if err != nil { return Page[syncdomain.Attachment]{}, err } - - values := url.Values{ - "limit": {strconv.Itoa(limit)}, - "offset": {strconv.Itoa(offset)}, - } + values := pageQuery(limit, offset) if query != "" { values.Set("filter", "query equals "+strconv.Quote(query)) } - - response, err := client.transport.MakeRequest(client.accessToken, http.MethodGet, endpoint, "", values, nil, false) + response, err := client.read(fmt.Sprintf("search %ss in project %q", kind, projectKey), projectKey, values, path...) if err != nil { - return Page[syncdomain.Attachment]{}, contextualAPIError( - err, - fmt.Sprintf("search %ss in project %q", kind, projectKey), - projectKey, - ) + return Page[syncdomain.Attachment]{}, err + } + page, err := decodeJSON[Page[json.RawMessage]](response, string(kind)+" search response") + if err != nil { + return Page[syncdomain.Attachment]{}, err } - switch kind { - case syncdomain.AttachmentTool: - var page Page[toolResponse] - if err := json.Unmarshal(response, &page); err != nil { - return Page[syncdomain.Attachment]{}, fmt.Errorf("decode tool search response: %w", err) + result := Page[syncdomain.Attachment]{ + Items: make([]syncdomain.Attachment, 0, len(page.Items)), + TotalCount: page.TotalCount, + } + for _, item := range page.Items { + attachment, err := decodeAttachment(kind, item) + if err != nil { + return Page[syncdomain.Attachment]{}, err } - items := make([]syncdomain.Attachment, 0, len(page.Items)) - for index := range page.Items { - item := page.Items[index] - // Tool search can return an older matching version, so resolve each - // key before presenting the result as the latest version. - attachment, err := client.ReadAttachment(projectKey, kind, item.Key) - if err != nil { + // Tool search can return an older version that matches the query. + // Read the key again to get the latest version. + if kind == syncdomain.AttachmentTool { + if attachment, err = client.ReadAttachment(projectKey, kind, attachment.Key()); err != nil { return Page[syncdomain.Attachment]{}, err } - items = append(items, attachment) - } - return Page[syncdomain.Attachment]{Items: items, TotalCount: page.TotalCount}, nil - - case syncdomain.AttachmentSkill: - var page Page[skillResponse] - if err := json.Unmarshal(response, &page); err != nil { - return Page[syncdomain.Attachment]{}, fmt.Errorf("decode skill search response: %w", err) - } - items := make([]syncdomain.Attachment, 0, len(page.Items)) - for index := range page.Items { - item := page.Items[index] - items = append(items, syncdomain.Attachment{Kind: kind, Version: item.Version, Skill: &item.Skill}) } - return Page[syncdomain.Attachment]{Items: items, TotalCount: page.TotalCount}, nil - - default: - return Page[syncdomain.Attachment]{}, fmt.Errorf("unsupported attachment kind %q", kind) + result.Items = append(result.Items, attachment) } + return result, nil } -// ReadAttachment returns the latest version of one project-scoped attachment. +// ReadAttachment returns the latest version of one tool or skill. func (client Client) ReadAttachment(projectKey string, kind syncdomain.AttachmentKind, key string) (syncdomain.Attachment, error) { - endpoint, err := client.attachmentEndpoint(projectKey, kind, key) + path, err := attachmentPath(projectKey, kind) if err != nil { return syncdomain.Attachment{}, err } - - response, err := client.transport.MakeRequest(client.accessToken, http.MethodGet, endpoint, "", nil, nil, false) + response, err := client.read(fmt.Sprintf("get %s %q in project %q", kind, key, projectKey), projectKey, nil, append(path, key)...) if err != nil { - return syncdomain.Attachment{}, contextualAPIError( - err, - fmt.Sprintf("get %s %q in project %q", kind, key, projectKey), - projectKey, - ) + return syncdomain.Attachment{}, err } attachment, err := decodeAttachment(kind, response) if err != nil { @@ -123,132 +99,94 @@ func (client Client) ReadAttachment(projectKey string, kind syncdomain.Attachmen return attachment, nil } -// UpdateAttachment creates a new version when canonical attachment content changed. -func (client Client) UpdateAttachment(projectKey string, attachment syncdomain.Attachment) error { - key := attachment.Key() - endpoint, err := client.attachmentEndpoint(projectKey, attachment.Kind, key) - if err != nil { - return err - } - - request, err := attachmentMutationRequest(attachment, false) - if err != nil { - return err - } - return client.mutateAttachment(http.MethodPatch, endpoint, "update", attachment, request) -} - -// CreateAttachment creates the first version of a locally defined tool. +// CreateAttachment creates the first version of a tool. Sync cannot create a skill. func (client Client) CreateAttachment(projectKey string, attachment syncdomain.Attachment) error { if attachment.Kind != syncdomain.AttachmentTool { return fmt.Errorf("creating %ss is not supported", attachment.Kind) } - endpoint, err := client.attachmentEndpoint(projectKey, attachment.Kind, "") - if err != nil { - return err - } - request, err := attachmentMutationRequest(attachment, true) - if err != nil { - return err - } - return client.mutateAttachment(http.MethodPost, endpoint, "create", attachment, request) + return client.mutateAttachment(projectKey, attachment, true) } -func attachmentMutationRequest(attachment syncdomain.Attachment, includeKey bool) (any, error) { - switch attachment.Kind { - case syncdomain.AttachmentTool: - if attachment.Tool == nil { - return nil, fmt.Errorf("tool content is required") - } - customParameters := attachment.Tool.CustomParameters - if customParameters == nil { - customParameters = map[string]any{} - } - tags := attachment.Tool.Tags - if tags == nil { - tags = []string{} - } - request := toolMutationRequest{ +// UpdateAttachment creates a new version of a tool or skill with new content. +func (client Client) UpdateAttachment(projectKey string, attachment syncdomain.Attachment) error { + return client.mutateAttachment(projectKey, attachment, false) +} + +func (client Client) mutateAttachment(projectKey string, attachment syncdomain.Attachment, create bool) error { + request := mutation{method: http.MethodPatch, action: "update", resource: string(attachment.Kind), key: attachment.Key()} + if create { + request.method, request.action = http.MethodPost, "create" + } + + switch { + case attachment.Kind == syncdomain.AttachmentTool && attachment.Tool != nil: + body := toolMutationRequest{ Description: attachment.Tool.Description, Schema: attachment.Tool.Schema, - CustomParameters: customParameters, - Tags: tags, + CustomParameters: emptyIfNil(attachment.Tool.CustomParameters), + Tags: attachment.Tool.Tags, } - if includeKey { - request.Key = attachment.Key() + if body.Tags == nil { + body.Tags = []string{} } - return request, nil - case syncdomain.AttachmentSkill: - if attachment.Skill == nil { - return nil, fmt.Errorf("skill content is required") + if create { + body.Key = attachment.Key() } - return skillMutationRequest{Description: attachment.Skill.Description, Markdown: attachment.Skill.Markdown}, nil + request.body = body + case attachment.Kind == syncdomain.AttachmentSkill && attachment.Skill != nil: + request.body = skillMutationRequest{Description: attachment.Skill.Description, Markdown: attachment.Skill.Markdown} default: - return nil, fmt.Errorf("unsupported attachment kind %q", attachment.Kind) + return fmt.Errorf("%s %q has no content", attachment.Kind, attachment.Key()) } -} -func (client Client) mutateAttachment(method, endpoint, action string, attachment syncdomain.Attachment, request any) error { - body, err := json.Marshal(request) + path, err := attachmentPath(projectKey, attachment.Kind) if err != nil { - return fmt.Errorf("encode %s %q: %w", attachment.Kind, attachment.Key(), err) + return err } - _, err = client.transport.MakeRequest(client.accessToken, method, endpoint, "application/json", nil, body, false) - if err != nil { - return newResourceMutationError(action, string(attachment.Kind), attachment.Key(), err) + if !create { + path = append(path, attachment.Key()) } - return nil + _, err = client.mutate(request, path...) + return err } -func (client Client) attachmentEndpoint(projectKey string, kind syncdomain.AttachmentKind, key string) (string, error) { - var parts []string +// attachmentPath returns the path of the tool or skill collection in a project. +func attachmentPath(projectKey string, kind syncdomain.AttachmentKind) ([]string, error) { switch kind { case syncdomain.AttachmentTool: - parts = []string{"api/v2/projects", projectKey, "ai-tools"} + return projectPath(projectKey, "ai-tools"), nil case syncdomain.AttachmentSkill: - parts = []string{"api/v2/projects", projectKey, "ai-configs/skills"} + return projectPath(projectKey, "ai-configs/skills"), nil default: - return "", fmt.Errorf("unsupported attachment kind %q", kind) - } - if key != "" { - parts = append(parts, key) + return nil, fmt.Errorf("unsupported attachment kind %q", kind) } - endpoint, err := url.JoinPath(client.baseURI, parts...) - if err != nil { - return "", fmt.Errorf("build %s endpoint: %w", kind, err) - } - return endpoint, nil } +// decodeAttachment decodes one tool or skill response and checks its identity. func decodeAttachment(kind syncdomain.AttachmentKind, data []byte) (syncdomain.Attachment, error) { var attachment syncdomain.Attachment switch kind { case syncdomain.AttachmentTool: - var response toolResponse - if err := json.Unmarshal(data, &response); err != nil { - return syncdomain.Attachment{}, fmt.Errorf("decode tool response: %w", err) + response, err := decodeJSON[toolResponse](data, "tool response") + if err != nil { + return syncdomain.Attachment{}, err } attachment = syncdomain.Attachment{Kind: kind, Version: response.Version, Tool: &response.Tool} case syncdomain.AttachmentSkill: - var response skillResponse - if err := json.Unmarshal(data, &response); err != nil { - return syncdomain.Attachment{}, fmt.Errorf("decode skill response: %w", err) + response, err := decodeJSON[skillResponse](data, "skill response") + if err != nil { + return syncdomain.Attachment{}, err } attachment = syncdomain.Attachment{Kind: kind, Version: response.Version, Skill: &response.Skill} default: return syncdomain.Attachment{}, fmt.Errorf("unsupported attachment kind %q", kind) } - if attachment.Key() == "" { + + switch { + case attachment.Key() == "": return syncdomain.Attachment{}, fmt.Errorf("decode %s response: key is required", kind) - } - if attachment.Version < 1 { + case attachment.Version < 1: return syncdomain.Attachment{}, fmt.Errorf("decode %s response: version must be positive", kind) } return attachment, nil } - -// IsNotFound reports whether a wrapped LaunchDarkly API error has a 404 status. -func IsNotFound(err error) bool { - status, ok := responseStatusCode(err) - return ok && status == http.StatusNotFound -} diff --git a/internal/sync/api/catalog.go b/internal/sync/api/catalog.go index b3c5b705..d503a3ee 100644 --- a/internal/sync/api/catalog.go +++ b/internal/sync/api/catalog.go @@ -3,27 +3,27 @@ package api import ( "encoding/json" "fmt" - "net/http" - "net/url" "strconv" "strings" syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) -// Project is the project metadata needed by interactive sync flows. +// Project is the project metadata that the interactive flows show. type Project struct { Key string `json:"key"` Name string `json:"name"` } -// Page is one server-filtered page of catalog resources. -type Page[T any] struct { - Items []T `json:"items"` - TotalCount int `json:"totalCount"` +// Config is one agent or completion config and its active variations. +type Config struct { + Key string `json:"key"` + Name string `json:"name"` + Mode syncdomain.VariationMode `json:"mode"` + Variations []syncdomain.Variation `json:"variations"` } -// ModelConfig contains the model settings assigned to a new variation. +// ModelConfig is a versioned set of model settings that a variation can use. type ModelConfig struct { Key string `json:"key"` ID string `json:"id"` @@ -33,159 +33,57 @@ type ModelConfig struct { CustomParams map[string]any `json:"customParams"` } -// VariationModel returns the model representation used by variation APIs. +// VariationModel returns the model settings in the form that a variation stores. func (config ModelConfig) VariationModel() map[string]any { - params := config.Params - if params == nil { - params = map[string]any{} - } - custom := config.CustomParams - if custom == nil { - custom = map[string]any{} - } return map[string]any{ "modelName": config.ID, - "parameters": params, - "custom": custom, - } -} - -// Config contains a supported config and its prompt variations. -type Config struct { - Key string `json:"key"` - Name string `json:"name"` - Mode syncdomain.VariationMode `json:"mode"` - Variations []syncdomain.Variation `json:"variations"` -} - -type configVariationResponse struct { - syncdomain.Variation - State string `json:"state"` -} - -// UnmarshalJSON excludes archived variations at the API boundary. LaunchDarkly -// retains archived variations in a config response, but sync treats them as -// absent and keeps lifecycle state out of the canonical variation model. -func (config *Config) UnmarshalJSON(data []byte) error { - var response struct { - Key string `json:"key"` - Name string `json:"name"` - Mode syncdomain.VariationMode `json:"mode"` - Variations []configVariationResponse `json:"variations"` - } - if err := json.Unmarshal(data, &response); err != nil { - return err - } - - config.Key = response.Key - config.Name = response.Name - config.Mode = response.Mode - config.Variations = make([]syncdomain.Variation, 0, len(response.Variations)) - for _, variation := range response.Variations { - if variation.State != "archived" { - config.Variations = append(config.Variations, variation.Variation) - } + "parameters": emptyIfNil(config.Params), + "custom": emptyIfNil(config.CustomParams), } - return nil } -// SearchProjects returns one name-sorted API page filtered by project name or key. +// SearchProjects returns one page of projects whose name or key matches query. func (client Client) SearchProjects(query string, limit, offset int) (Page[Project], error) { - endpoint, err := url.JoinPath(client.baseURI, "api/v2/projects") - if err != nil { - return Page[Project]{}, fmt.Errorf("build projects endpoint: %w", err) - } - - values := url.Values{ - "limit": {strconv.Itoa(limit)}, - "offset": {strconv.Itoa(offset)}, - "sort": {"name"}, - } + values := pageQuery(limit, offset) + values.Set("sort", "name") if query != "" { values.Set("filter", "query:"+query) } - response, err := client.transport.MakeRequest(client.accessToken, http.MethodGet, endpoint, "", values, nil, false) - if err != nil { - return Page[Project]{}, contextualAPIError(err, "search projects", "") - } - - var page Page[Project] - if err := json.Unmarshal(response, &page); err != nil { - return Page[Project]{}, fmt.Errorf("decode projects response: %w", err) - } - return page, nil -} - -// ModelConfigs returns model configs available to one project. -func (client Client) ModelConfigs(projectKey string) ([]ModelConfig, error) { - endpoint, err := url.JoinPath(client.baseURI, "api/v2/projects", projectKey, "ai-configs/model-configs") - if err != nil { - return nil, fmt.Errorf("build model configs endpoint: %w", err) - } - - response, err := client.transport.MakeRequest( - client.accessToken, - http.MethodGet, - endpoint, - "", - nil, - nil, - false, - ) + response, err := client.read("search projects", "", values, "api/v2/projects") if err != nil { - return nil, contextualAPIError( - err, - fmt.Sprintf("list model configs in project %q", projectKey), - projectKey, - ) - } - - var modelConfigs []ModelConfig - if err := json.Unmarshal(response, &modelConfigs); err != nil { - return nil, fmt.Errorf("decode model configs response: %w", err) + return Page[Project]{}, err } - - return modelConfigs, nil + return decodeJSON[Page[Project]](response, "projects response") } -// SearchConfigs returns one name-sorted page of agent and completion configs. +// SearchConfigs returns one page of configs that match query and use one of +// modes. If modes is empty, the page includes agent and completion configs. func (client Client) SearchConfigs(projectKey, query string, modes []syncdomain.VariationMode, limit, offset int) (Page[Config], error) { - endpoint, err := url.JoinPath(client.baseURI, "api/v2/projects", projectKey, "ai-configs") - if err != nil { - return Page[Config]{}, fmt.Errorf("build configs endpoint: %w", err) - } - if len(modes) == 0 { modes = []syncdomain.VariationMode{syncdomain.VariationModeAgent, syncdomain.VariationModeCompletion} } - modeValues := make([]string, len(modes)) + quotedModes := make([]string, len(modes)) for index, mode := range modes { - modeValues[index] = strconv.Quote(string(mode)) + quotedModes[index] = strconv.Quote(string(mode)) } - - filter := "mode anyOf [" + strings.Join(modeValues, ",") + "]" + filter := "mode anyOf [" + strings.Join(quotedModes, ",") + "]" if query != "" { filter = "query equals " + strconv.Quote(query) + ", " + filter } - values := url.Values{ - "filter": {filter}, - "limit": {strconv.Itoa(limit)}, - "offset": {strconv.Itoa(offset)}, - "sort": {"name"}, - } - response, err := client.transport.MakeRequest(client.accessToken, http.MethodGet, endpoint, "", values, nil, false) + values := pageQuery(limit, offset) + values.Set("sort", "name") + values.Set("filter", filter) + response, err := client.read( + fmt.Sprintf("search configs in project %q", projectKey), projectKey, values, + projectPath(projectKey, "ai-configs")..., + ) if err != nil { - return Page[Config]{}, contextualAPIError( - err, - fmt.Sprintf("search configs in project %q", projectKey), - projectKey, - ) + return Page[Config]{}, err } - - var page Page[Config] - if err := json.Unmarshal(response, &page); err != nil { - return Page[Config]{}, fmt.Errorf("decode configs response: %w", err) + page, err := decodeJSON[Page[Config]](response, "configs response") + if err != nil { + return Page[Config]{}, err } for index := range page.Items { if err := page.Items[index].applyMode(); err != nil { @@ -195,58 +93,87 @@ func (client Client) SearchConfigs(projectKey, query string, modes []syncdomain. return page, nil } -// Config returns one config with its variation modes normalized. +// Config returns one config. Each variation has the mode of the config. func (client Client) Config(projectKey, configKey string) (Config, error) { - endpoint, err := url.JoinPath(client.baseURI, "api/v2/projects", projectKey, "ai-configs", configKey) + response, err := client.read( + fmt.Sprintf("get config %q in project %q", configKey, projectKey), projectKey, nil, + projectPath(projectKey, "ai-configs", configKey)..., + ) + if err != nil { + return Config{}, err + } + config, err := decodeJSON[Config](response, "config response") + if err != nil { + return Config{}, err + } + if err := config.applyMode(); err != nil { + return Config{}, err + } + return config, nil +} + +// ModelConfig returns the latest version of one model config. +func (client Client) ModelConfig(projectKey, modelConfigKey string) (ModelConfig, error) { + response, err := client.read( + fmt.Sprintf("get model config %q in project %q", modelConfigKey, projectKey), projectKey, nil, + projectPath(projectKey, "ai-configs/model-configs", modelConfigKey)..., + ) if err != nil { - return Config{}, fmt.Errorf("build config endpoint: %w", err) + return ModelConfig{}, err } + return decodeJSON[ModelConfig](response, "model config response") +} - response, err := client.transport.MakeRequest( - client.accessToken, - http.MethodGet, - endpoint, - "", - nil, - nil, - false, +// ModelConfigs returns every model config that a project can use. +func (client Client) ModelConfigs(projectKey string) ([]ModelConfig, error) { + response, err := client.read( + fmt.Sprintf("list model configs in project %q", projectKey), projectKey, nil, + projectPath(projectKey, "ai-configs/model-configs")..., ) if err != nil { - return Config{}, contextualAPIError( - err, - fmt.Sprintf("get config %q in project %q", configKey, projectKey), - projectKey, - ) + return nil, err } + return decodeJSON[[]ModelConfig](response, "model configs response") +} - var config Config - if err := json.Unmarshal(response, &config); err != nil { - return Config{}, fmt.Errorf("decode config response: %w", err) +// UnmarshalJSON drops archived variations. LaunchDarkly returns them in a +// config, but sync treats an archived variation as absent. +func (config *Config) UnmarshalJSON(data []byte) error { + var response struct { + Key string `json:"key"` + Name string `json:"name"` + Mode syncdomain.VariationMode `json:"mode"` + Variations []struct { + syncdomain.Variation + State string `json:"state"` + } `json:"variations"` } - if err := config.applyMode(); err != nil { - return Config{}, err + if err := json.Unmarshal(data, &response); err != nil { + return err } - return config, nil + *config = Config{Key: response.Key, Name: response.Name, Mode: response.Mode} + config.Variations = make([]syncdomain.Variation, 0, len(response.Variations)) + for _, variation := range response.Variations { + if variation.State != "archived" { + config.Variations = append(config.Variations, variation.Variation) + } + } + return nil } -// applyMode validates the parent config mode and copies it onto every -// variation, whose API representation does not contain its own mode. +// applyMode copies the config mode to each variation, because the API does +// not include the mode in a variation. A config without a mode is a +// completion config. func (config *Config) applyMode() error { if config.Mode == "" { config.Mode = syncdomain.VariationModeCompletion } if !config.Mode.Valid() { - return fmt.Errorf( - "config %q has unsupported mode %q", - config.Key, - config.Mode, - ) + return fmt.Errorf("config %q has unsupported mode %q", config.Key, config.Mode) } - for index := range config.Variations { config.Variations[index].Mode = config.Mode } - return nil } diff --git a/internal/sync/api/client.go b/internal/sync/api/client.go index 68d2c1d5..eee528e9 100644 --- a/internal/sync/api/client.go +++ b/internal/sync/api/client.go @@ -1,489 +1,100 @@ +// Package api is the LaunchDarkly REST client for sync. It reads and writes +// config variations, tools, skills, model configs, and sync manifests. package api import ( "encoding/json" - "errors" "fmt" "net/http" "net/url" - "strings" + "strconv" - lderrors "github.com/launchdarkly/ldcli/internal/errors" "github.com/launchdarkly/ldcli/internal/resources" - syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) -// Client reads and mutates prompt variations through the existing config endpoints. +// Client calls the LaunchDarkly REST API with one access token. type Client struct { transport resources.Client accessToken string baseURI string } -// VariationState includes the parent config mode even when the requested -// variation does not exist. -type VariationState struct { - Variation syncdomain.Variation - Exists bool - ConfigMode syncdomain.VariationMode -} - -// SyncManifest is the complete synchronization baseline for one project and source. -type SyncManifest struct { - Source string `json:"source"` - Items []SyncManifestResource `json:"items"` -} - -// SyncManifestResource is one versioned baseline entry returned by LaunchDarkly. -type SyncManifestResource struct { - ResourceKind syncdomain.Kind `json:"resourceKind"` - ResourceLookupKey string `json:"resourceLookupKey"` - Fingerprint string `json:"fingerprint"` - Version int `json:"version"` -} - -// SyncManifestUpsert creates or updates one baseline entry. -type SyncManifestUpsert struct { - ResourceKind syncdomain.Kind `json:"resourceKind"` - ResourceLookupKey string `json:"resourceLookupKey"` - Fingerprint string `json:"fingerprint"` - Version int `json:"version"` -} - -// SyncManifestDeletion removes one baseline entry at its expected version. -type SyncManifestDeletion struct { - ResourceKind syncdomain.Kind `json:"resourceKind"` - ResourceLookupKey string `json:"resourceLookupKey"` - Version int `json:"version"` -} - -type patchSyncManifestRequest struct { - Source string `json:"source"` - Upserts []SyncManifestUpsert `json:"upserts"` - Deletions []SyncManifestDeletion `json:"deletions"` -} - -type createVariationRequest struct { - Key string `json:"key"` - Name string `json:"name"` - Instructions string `json:"instructions,omitempty"` - ModelConfigKey string `json:"modelConfigKey,omitempty"` - ModelConfigVersion int `json:"modelConfigVersion,omitempty"` - Model map[string]any `json:"model,omitempty"` - OutputFormat map[string]any `json:"outputFormat,omitempty"` - Messages []syncdomain.Message `json:"messages,omitempty"` - Tools []syncdomain.AttachmentRef `json:"tools,omitempty"` - Skills []syncdomain.AttachmentRef `json:"skills,omitempty"` -} - -type updateVariationRequest struct { - Name string `json:"name"` - Instructions *string `json:"instructions,omitempty"` - ModelConfigKey string `json:"modelConfigKey"` - ModelConfigVersion int `json:"modelConfigVersion,omitempty"` - Model map[string]any `json:"model"` - OutputFormat map[string]any `json:"outputFormat"` - Messages *[]syncdomain.Message `json:"messages,omitempty"` - Tools *[]syncdomain.AttachmentRef `json:"tools,omitempty"` - Skills *[]syncdomain.AttachmentRef `json:"skills,omitempty"` -} - -type mutationError struct { - err error - message string - uncertain bool -} - -// Error returns the contextual mutation failure shown to the user. -func (err mutationError) Error() string { - return err.message -} - -// Unwrap exposes the transport or API error for errors.Is and errors.As. -func (err mutationError) Unwrap() error { - return err.err -} - -// MutationMayHaveSucceeded reports whether a transport failure prevented the -// client from receiving a definitive API response. -func MutationMayHaveSucceeded(err error) bool { - var mutation mutationError - return errors.As(err, &mutation) && mutation.uncertain -} - -// NewClient creates a direct config variation client. +// NewClient creates a client for the API at baseURI. func NewClient(transport resources.Client, accessToken, baseURI string) Client { - return Client{ - transport: transport, - accessToken: accessToken, - baseURI: baseURI, - } -} - -// GetSyncManifest returns the synchronization baseline for one project and source. -func (client Client) GetSyncManifest(projectKey, source string) (SyncManifest, error) { - endpoint, err := client.syncManifestEndpoint(projectKey) - if err != nil { - return SyncManifest{}, err - } - - response, err := client.transport.MakeRequest( - client.accessToken, - http.MethodGet, - endpoint, - "", - url.Values{"source": []string{source}}, - nil, - false, - ) - if err != nil { - return SyncManifest{}, contextualAPIError( - err, - fmt.Sprintf("get sync manifest for project %q", projectKey), - projectKey, - ) - } - - var manifest SyncManifest - if err := json.Unmarshal(response, &manifest); err != nil { - return SyncManifest{}, fmt.Errorf("decode sync manifest for project %q: %w", projectKey, err) - } - return manifest, nil -} - -// PatchSyncManifest applies versioned baseline changes and returns the refreshed manifest. -func (client Client) PatchSyncManifest( - projectKey string, - source string, - upserts []SyncManifestUpsert, - deletions []SyncManifestDeletion, -) (SyncManifest, error) { - endpoint, err := client.syncManifestEndpoint(projectKey) - if err != nil { - return SyncManifest{}, err - } - if upserts == nil { - upserts = []SyncManifestUpsert{} - } - if deletions == nil { - deletions = []SyncManifestDeletion{} - } - body, err := json.Marshal(patchSyncManifestRequest{ - Source: source, Upserts: upserts, Deletions: deletions, - }) - if err != nil { - return SyncManifest{}, fmt.Errorf("encode sync manifest changes for project %q: %w", projectKey, err) - } - - response, err := client.transport.MakeRequest( - client.accessToken, - http.MethodPatch, - endpoint, - "application/json", - nil, - body, - false, - ) - if err != nil { - return SyncManifest{}, newResourceMutationError("update", "sync manifest for project", projectKey, err) - } - - var manifest SyncManifest - if err := json.Unmarshal(response, &manifest); err != nil { - return SyncManifest{}, newResourceMutationError( - "decode updated", - "sync manifest for project", - projectKey, - err, - ) - } - return manifest, nil -} - -// ModelConfig returns the latest version of one model config. -func (client Client) ModelConfig(projectKey, modelConfigKey string) (ModelConfig, error) { - endpoint, err := url.JoinPath(client.baseURI, "api/v2/projects", projectKey, "ai-configs/model-configs", modelConfigKey) - if err != nil { - return ModelConfig{}, fmt.Errorf("build model config endpoint: %w", err) - } - - response, err := client.transport.MakeRequest(client.accessToken, http.MethodGet, endpoint, "", nil, nil, false) - if err != nil { - return ModelConfig{}, contextualAPIError( - err, - fmt.Sprintf("get model config %q in project %q", modelConfigKey, projectKey), - projectKey, - ) - } - - var modelConfig ModelConfig - if err := json.Unmarshal(response, &modelConfig); err != nil { - return ModelConfig{}, fmt.Errorf("decode model config response: %w", err) - } - return modelConfig, nil + return Client{transport: transport, accessToken: accessToken, baseURI: baseURI} } -// ReadVariation returns one variation and its parent config mode. -func (client Client) ReadVariation(projectKey, configKey, variationKey string) (VariationState, error) { - config, err := client.Config(projectKey, configKey) - if err != nil { - return VariationState{}, err - } - - for _, variation := range config.Variations { - if variation.Key != variationKey { - continue - } - if err := syncdomain.ValidateDirectAPIVariation(variation); err != nil { - return VariationState{}, fmt.Errorf( - "read config variation %q: %w", - variationKey, - err, - ) - } - return VariationState{Variation: variation, Exists: true, ConfigMode: config.Mode}, nil - } - - return VariationState{ConfigMode: config.Mode}, nil +// Page is one server-filtered page of catalog items. +type Page[T any] struct { + Items []T `json:"items"` + TotalCount int `json:"totalCount"` } -// CreateVariation creates a variation using only fields supported by the -// existing public endpoint. -func (client Client) CreateVariation(projectKey, configKey string, variation syncdomain.Variation) error { - if err := syncdomain.ValidateDirectAPIVariation(variation); err != nil { - return fmt.Errorf("create config variation %q: %w", variation.Key, err) - } - - request := createVariationRequest{ - Key: variation.Key, - Name: variation.Name, - ModelConfigKey: variation.ModelConfigKey, - ModelConfigVersion: variation.ModelConfigVersion, - Model: variation.Model, - OutputFormat: variation.OutputFormat, - Tools: variation.Tools, - Skills: variation.Skills, - } - if variation.Mode == syncdomain.VariationModeAgent { - request.Instructions = variation.Instructions - } else { - request.Messages = variation.Messages - } - - body, err := json.Marshal(request) - if err != nil { - return fmt.Errorf("encode config variation %q: %w", variation.Key, err) - } - - endpoint, err := client.variationEndpoint(projectKey, configKey) - if err != nil { - return err - } - - _, err = client.transport.MakeRequest( - client.accessToken, - http.MethodPost, - endpoint, - "application/json", - nil, - body, - false, - ) - if err != nil { - return newMutationError("create", variation.Key, err) - } - - return nil +// mutation describes one write request. The action, resource, and key name +// the write in error messages, for example `create config variation "x"`. +type mutation struct { + method string + action string + resource string + key string + body any } -// UpdateVariation replaces all supported, locally owned variation fields. -func (client Client) UpdateVariation(projectKey, configKey string, variation syncdomain.Variation) error { - if err := syncdomain.ValidateDirectAPIVariation(variation); err != nil { - return fmt.Errorf("update config variation %q: %w", variation.Key, err) - } - - model := variation.Model - if model == nil { - model = map[string]any{} - } - outputFormat := variation.OutputFormat - if outputFormat == nil { - // The API treats an omitted or null outputFormat as unchanged. Send an - // empty object when the local field is absent so removing it also syncs. - outputFormat = map[string]any{} - } - request := updateVariationRequest{ - Name: variation.Name, - ModelConfigKey: variation.ModelConfigKey, - ModelConfigVersion: variation.ModelConfigVersion, - Model: model, - OutputFormat: outputFormat, - } - if variation.Tools != nil { - request.Tools = &variation.Tools - } - if variation.Skills != nil { - request.Skills = &variation.Skills - } - - // Agent and completion configs reject fields owned by the other mode, even - // when those fields are empty. Include only the prompt field for this mode. - if variation.Mode == syncdomain.VariationModeAgent { - request.Instructions = &variation.Instructions - } else { - messages := variation.Messages - if messages == nil { - messages = []syncdomain.Message{} - } - request.Messages = &messages - } - - body, err := json.Marshal(request) - if err != nil { - return fmt.Errorf("encode config variation %q: %w", variation.Key, err) - } - - endpoint, err := client.variationEndpoint(projectKey, configKey, variation.Key) +// read sends a GET request. If the request fails, the error starts with the +// description and suggests a fix when the resource does not exist in projectKey. +func (client Client) read(description, projectKey string, query url.Values, path ...string) ([]byte, error) { + endpoint, err := client.endpoint(path...) if err != nil { - return err + return nil, err } - - _, err = client.transport.MakeRequest( - client.accessToken, - http.MethodPatch, - endpoint, - "application/json", - nil, - body, - false, - ) + response, err := client.transport.MakeRequest(client.accessToken, http.MethodGet, endpoint, "", query, nil, false) if err != nil { - return newMutationError("update", variation.Key, err) + return nil, contextualAPIError(err, description, projectKey) } - - return nil + return response, nil } -// ArchiveVariation archives one variation without permanently deleting it. -func (client Client) ArchiveVariation(projectKey, configKey, variationKey string) error { - endpoint, err := client.variationEndpoint(projectKey, configKey, variationKey) +// mutate sends one JSON write request. The returned error records whether +// the write can have succeeded without a response. +func (client Client) mutate(request mutation, path ...string) ([]byte, error) { + body, err := json.Marshal(request.body) if err != nil { - return err + return nil, fmt.Errorf("encode %s %q: %w", request.resource, request.key, err) } - - _, err = client.transport.MakeRequest( - client.accessToken, - http.MethodPatch, - endpoint, - "application/json", - nil, - []byte(`{"state":"archived"}`), - false, - ) + endpoint, err := client.endpoint(path...) if err != nil { - return newMutationError("archive", variationKey, err) - } - - return nil -} - -// variationEndpoint builds an escaped direct-variation API URL. -func (client Client) variationEndpoint(projectKey, configKey string, path ...string) (string, error) { - parts := []string{ - "api/v2/projects", - projectKey, - "ai-configs", - configKey, - "variations", + return nil, err } - parts = append(parts, path...) - - endpoint, err := url.JoinPath(client.baseURI, parts...) + response, err := client.transport.MakeRequest(client.accessToken, request.method, endpoint, "application/json", nil, body, false) if err != nil { - return "", fmt.Errorf("build config variation endpoint: %w", err) + return nil, newMutationError(request.action, request.resource, request.key, err) } - return endpoint, nil + return response, nil } -func (client Client) syncManifestEndpoint(projectKey string) (string, error) { - endpoint, err := url.JoinPath( - client.baseURI, - "api/v2/projects", - projectKey, - "configs/sync/manifests", - ) +func (client Client) endpoint(path ...string) (string, error) { + endpoint, err := url.JoinPath(client.baseURI, path...) if err != nil { - return "", fmt.Errorf("build sync manifest endpoint: %w", err) + return "", fmt.Errorf("build API endpoint: %w", err) } return endpoint, nil } -// newMutationError records whether a failed request received a definitive API -// response. Errors without a status code may represent a committed write whose -// response was lost, so the reconciliation layer verifies those with a read. -func newMutationError(action, variationKey string, err error) error { - return newResourceMutationError(action, "config variation", variationKey, err) +// projectPath returns the path of a resource in one project. +func projectPath(projectKey string, path ...string) []string { + return append([]string{"api/v2/projects", projectKey}, path...) } -func newResourceMutationError(action, resource, key string, err error) error { - _, definitiveResponse := responseStatusCode(err) - contextual := contextualAPIError( - err, - fmt.Sprintf("%s %s %q", action, resource, key), - "", - ) - - return mutationError{ - err: contextual, - message: contextual.Error(), - uncertain: !definitiveResponse, - } -} - -func responseStatusCode(err error) (int, bool) { - response, ok := responseError(err) - if !ok { - return 0, false - } - status, ok := response["statusCode"].(float64) - return int(status), ok && status != 0 +// pageQuery returns the query values for one catalog page. +func pageQuery(limit, offset int) url.Values { + return url.Values{"limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(offset)}} } -// contextualAPIError keeps the API's structured status fields while adding -// the resource identity needed to act on the failure. -func contextualAPIError(err error, context, projectKey string) error { - response, ok := responseError(err) - if !ok { - return fmt.Errorf("%s: %w", context, err) +// decodeJSON decodes one response. The description names the response in errors. +func decodeJSON[T any](data []byte, description string) (T, error) { + var value T + if err := json.Unmarshal(data, &value); err != nil { + return value, fmt.Errorf("decode %s: %w", description, err) } - status, _ := response["statusCode"].(float64) - if int(status) == http.StatusNotFound && projectKey != "" { - response["message"] = context - response["suggestion"] = fmt.Sprintf( - "Verify the resource key and that it belongs to project %q.", - projectKey, - ) - } else if message, _ := response["message"].(string); message != "" { - response["message"] = context + ": " + strings.ReplaceAll(message, "AI config", "config") - } else { - response["message"] = context - } - body, _ := json.Marshal(response) - return lderrors.NewErrorWrapped(string(body), err) -} - -func responseError(err error) (map[string]any, bool) { - for current := err; current != nil; current = errors.Unwrap(current) { - var response map[string]any - if json.Unmarshal([]byte(current.Error()), &response) == nil && len(response) != 0 { - return response, true - } - } - return nil, false -} - -// IsConflict reports whether a wrapped LaunchDarkly API error has a 409 status. -func IsConflict(err error) bool { - status, ok := responseStatusCode(err) - return ok && status == http.StatusConflict + return value, nil } diff --git a/internal/sync/api/errors.go b/internal/sync/api/errors.go new file mode 100644 index 00000000..8b937c2d --- /dev/null +++ b/internal/sync/api/errors.go @@ -0,0 +1,94 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + lderrors "github.com/launchdarkly/ldcli/internal/errors" +) + +// IsNotFound reports whether a LaunchDarkly API error has a 404 status. +func IsNotFound(err error) bool { + status, ok := responseStatusCode(err) + return ok && status == http.StatusNotFound +} + +// IsConflict reports whether a LaunchDarkly API error has a 409 status. +func IsConflict(err error) bool { + status, ok := responseStatusCode(err) + return ok && status == http.StatusConflict +} + +// MutationMayHaveSucceeded reports whether a write failed before the client +// received a response with a status code. The write can still have succeeded, +// so the caller must read the resource to find its state. +func MutationMayHaveSucceeded(err error) bool { + var mutation mutationError + return errors.As(err, &mutation) && mutation.uncertain +} + +type mutationError struct { + err error + uncertain bool +} + +func (err mutationError) Error() string { return err.err.Error() } +func (err mutationError) Unwrap() error { return err.err } + +// newMutationError adds the write description to err. It also records whether +// the API sent a status code, which is a definite result. +func newMutationError(action, resource, key string, err error) error { + _, definite := responseStatusCode(err) + return mutationError{ + err: contextualAPIError(err, fmt.Sprintf("%s %s %q", action, resource, key), ""), + uncertain: !definite, + } +} + +// contextualAPIError adds a description to an API error. It keeps the +// structured fields of the response. If the resource is not found in +// projectKey, the error suggests a fix. +func contextualAPIError(err error, description, projectKey string) error { + response, ok := responseError(err) + if !ok { + return fmt.Errorf("%s: %w", description, err) + } + + status, _ := response["statusCode"].(float64) + message, _ := response["message"].(string) + switch { + case int(status) == http.StatusNotFound && projectKey != "": + response["message"] = description + response["suggestion"] = fmt.Sprintf("Verify the resource key and that it belongs to project %q.", projectKey) + case message != "": + response["message"] = description + ": " + strings.ReplaceAll(message, "AI config", "config") + default: + response["message"] = description + } + body, _ := json.Marshal(response) + return lderrors.NewErrorWrapped(string(body), err) +} + +func responseStatusCode(err error) (int, bool) { + response, ok := responseError(err) + if !ok { + return 0, false + } + status, ok := response["statusCode"].(float64) + return int(status), ok && status != 0 +} + +// responseError finds the JSON response body in an error chain. The resources +// client reports an API failure as an error whose text is the response body. +func responseError(err error) (map[string]any, bool) { + for current := err; current != nil; current = errors.Unwrap(current) { + var response map[string]any + if json.Unmarshal([]byte(current.Error()), &response) == nil && len(response) != 0 { + return response, true + } + } + return nil, false +} diff --git a/internal/sync/api/manifest.go b/internal/sync/api/manifest.go new file mode 100644 index 00000000..ad997bc9 --- /dev/null +++ b/internal/sync/api/manifest.go @@ -0,0 +1,92 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +// SyncManifest is the sync baseline that LaunchDarkly stores for one project +// and one repository source. +type SyncManifest struct { + Source string `json:"source"` + Items []SyncManifestResource `json:"items"` +} + +// SyncManifestResource is one versioned baseline entry. +type SyncManifestResource struct { + ResourceKind syncdomain.Kind `json:"resourceKind"` + ResourceLookupKey string `json:"resourceLookupKey"` + Fingerprint string `json:"fingerprint"` + Version int `json:"version"` +} + +// SyncManifestUpsert creates an entry, or updates the entry at Version. +type SyncManifestUpsert struct { + ResourceKind syncdomain.Kind `json:"resourceKind"` + ResourceLookupKey string `json:"resourceLookupKey"` + Fingerprint string `json:"fingerprint"` + Version int `json:"version"` +} + +// SyncManifestDeletion removes the entry at Version. +type SyncManifestDeletion struct { + ResourceKind syncdomain.Kind `json:"resourceKind"` + ResourceLookupKey string `json:"resourceLookupKey"` + Version int `json:"version"` +} + +// GetSyncManifest returns the baseline for one project and source. +func (client Client) GetSyncManifest(projectKey, source string) (SyncManifest, error) { + response, err := client.read( + fmt.Sprintf("get sync manifest for project %q", projectKey), projectKey, + url.Values{"source": {source}}, manifestPath(projectKey)..., + ) + if err != nil { + return SyncManifest{}, err + } + return decodeJSON[SyncManifest](response, fmt.Sprintf("sync manifest for project %q", projectKey)) +} + +// PatchSyncManifest applies versioned changes and returns the new baseline. +func (client Client) PatchSyncManifest( + projectKey string, + source string, + upserts []SyncManifestUpsert, + deletions []SyncManifestDeletion, +) (SyncManifest, error) { + // The API requires both lists, so send an empty list instead of null. + if upserts == nil { + upserts = []SyncManifestUpsert{} + } + if deletions == nil { + deletions = []SyncManifestDeletion{} + } + request := mutation{ + method: http.MethodPatch, action: "update", resource: "sync manifest for project", key: projectKey, + body: struct { + Source string `json:"source"` + Upserts []SyncManifestUpsert `json:"upserts"` + Deletions []SyncManifestDeletion `json:"deletions"` + }{Source: source, Upserts: upserts, Deletions: deletions}, + } + response, err := client.mutate(request, manifestPath(projectKey)...) + if err != nil { + return SyncManifest{}, err + } + + var manifest SyncManifest + if err := json.Unmarshal(response, &manifest); err != nil { + // The API accepted the patch, but the response is not usable. The + // caller must read the manifest again to find its state. + return SyncManifest{}, newMutationError("decode updated", request.resource, projectKey, err) + } + return manifest, nil +} + +func manifestPath(projectKey string) []string { + return projectPath(projectKey, "configs/sync/manifests") +} diff --git a/internal/sync/api/variation.go b/internal/sync/api/variation.go new file mode 100644 index 00000000..71bb251b --- /dev/null +++ b/internal/sync/api/variation.go @@ -0,0 +1,140 @@ +package api + +import ( + "net/http" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +const variationResource = "config variation" + +// VariationState is one variation as LaunchDarkly stores it. ConfigMode is set +// even when the variation does not exist, because the parent config owns it. +type VariationState struct { + Variation syncdomain.Variation + Exists bool + ConfigMode syncdomain.VariationMode +} + +type createVariationRequest struct { + Key string `json:"key"` + Name string `json:"name"` + Instructions string `json:"instructions,omitempty"` + ModelConfigKey string `json:"modelConfigKey,omitempty"` + ModelConfigVersion int `json:"modelConfigVersion,omitempty"` + Model map[string]any `json:"model,omitempty"` + OutputFormat map[string]any `json:"outputFormat,omitempty"` + Messages []syncdomain.Message `json:"messages,omitempty"` + Tools []syncdomain.AttachmentRef `json:"tools,omitempty"` + Skills []syncdomain.AttachmentRef `json:"skills,omitempty"` +} + +// updateVariationRequest sends every field that sync owns, so that a field +// that is empty in the local file is also cleared in LaunchDarkly. +type updateVariationRequest struct { + Name string `json:"name"` + Instructions *string `json:"instructions,omitempty"` + ModelConfigKey string `json:"modelConfigKey"` + ModelConfigVersion int `json:"modelConfigVersion,omitempty"` + Model map[string]any `json:"model"` + OutputFormat map[string]any `json:"outputFormat"` + Messages *[]syncdomain.Message `json:"messages,omitempty"` + Tools *[]syncdomain.AttachmentRef `json:"tools,omitempty"` + Skills *[]syncdomain.AttachmentRef `json:"skills,omitempty"` +} + +// ReadVariation returns one variation and the mode of its parent config. +func (client Client) ReadVariation(projectKey, configKey, variationKey string) (VariationState, error) { + config, err := client.Config(projectKey, configKey) + if err != nil { + return VariationState{}, err + } + state := VariationState{ConfigMode: config.Mode} + for _, variation := range config.Variations { + if variation.Key == variationKey { + state.Variation, state.Exists = variation, true + break + } + } + return state, nil +} + +// CreateVariation creates a variation in a config. +func (client Client) CreateVariation(projectKey, configKey string, variation syncdomain.Variation) error { + request := createVariationRequest{ + Key: variation.Key, + Name: variation.Name, + ModelConfigKey: variation.ModelConfigKey, + ModelConfigVersion: variation.ModelConfigVersion, + Model: variation.Model, + OutputFormat: variation.OutputFormat, + Tools: variation.Tools, + Skills: variation.Skills, + } + // An agent config rejects messages and a completion config rejects + // instructions, so the request includes only the field for this mode. + if variation.Mode == syncdomain.VariationModeAgent { + request.Instructions = variation.Instructions + } else { + request.Messages = variation.Messages + } + + _, err := client.mutate(mutation{ + method: http.MethodPost, action: "create", resource: variationResource, key: variation.Key, body: request, + }, variationPath(projectKey, configKey)...) + return err +} + +// UpdateVariation replaces every field of the variation that sync owns. +func (client Client) UpdateVariation(projectKey, configKey string, variation syncdomain.Variation) error { + // The API keeps the current value when model or outputFormat is absent or + // null. An empty object clears the value. + request := updateVariationRequest{ + Name: variation.Name, + ModelConfigKey: variation.ModelConfigKey, + ModelConfigVersion: variation.ModelConfigVersion, + Model: emptyIfNil(variation.Model), + OutputFormat: emptyIfNil(variation.OutputFormat), + } + if variation.Tools != nil { + request.Tools = &variation.Tools + } + if variation.Skills != nil { + request.Skills = &variation.Skills + } + if variation.Mode == syncdomain.VariationModeAgent { + request.Instructions = &variation.Instructions + } else { + messages := variation.Messages + if messages == nil { + messages = []syncdomain.Message{} + } + request.Messages = &messages + } + + _, err := client.mutate(mutation{ + method: http.MethodPatch, action: "update", resource: variationResource, key: variation.Key, body: request, + }, variationPath(projectKey, configKey, variation.Key)...) + return err +} + +// ArchiveVariation archives one variation. LaunchDarkly keeps an archived +// variation, but sync treats it as absent. +func (client Client) ArchiveVariation(projectKey, configKey, variationKey string) error { + _, err := client.mutate(mutation{ + method: http.MethodPatch, action: "archive", resource: variationResource, key: variationKey, + body: map[string]string{"state": "archived"}, + }, variationPath(projectKey, configKey, variationKey)...) + return err +} + +func variationPath(projectKey, configKey string, path ...string) []string { + return projectPath(projectKey, append([]string{"ai-configs", configKey, "variations"}, path...)...) +} + +func emptyIfNil(value map[string]any) map[string]any { + if value == nil { + return map[string]any{} + } + return value +} diff --git a/internal/sync/attachment.go b/internal/sync/attachment.go new file mode 100644 index 00000000..ffe642f7 --- /dev/null +++ b/internal/sync/attachment.go @@ -0,0 +1,111 @@ +package sync + +import ( + "encoding/json" + "slices" +) + +// AttachmentKind identifies a versioned resource that a variation references. +type AttachmentKind string + +const ( + AttachmentTool AttachmentKind = "tool" + AttachmentSkill AttachmentKind = "skill" +) + +// AttachmentKinds lists every attachment kind in canonical order. +var AttachmentKinds = []AttachmentKind{AttachmentTool, AttachmentSkill} + +// Valid reports whether sync supports the attachment kind. +func (kind AttachmentKind) Valid() bool { + return slices.Contains(AttachmentKinds, kind) +} + +// AttachmentRef is a reference from a variation to one tool or skill. Local +// files store only the key. The API also pins an exact version. +type AttachmentRef struct { + Key string `json:"key" yaml:"key"` + Version int `json:"version,omitempty" yaml:"-"` +} + +// Tool is the version-independent content of an AI tool. +type Tool struct { + Key string `json:"key" yaml:"key"` + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + Schema map[string]any `json:"schema" yaml:"schema"` + CustomParameters map[string]any `json:"customParameters,omitempty" yaml:"customParameters,omitempty"` + Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` +} + +// Skill is one Markdown file and its catalog metadata. Sync owns Key, +// Description, and Markdown. LaunchDarkly owns Name. +type Skill struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Markdown string `json:"markdown"` +} + +// Attachment is the content of one tool or one skill, with the latest version +// that sync read from LaunchDarkly. Exactly one of Tool and Skill is set. +// +// The JSON encoding of this struct is part of the variation fingerprint. +type Attachment struct { + Kind AttachmentKind + Version int + // Upsert lets sync create the tool in LaunchDarkly when it is absent. + Upsert bool + Tool *Tool + Skill *Skill +} + +// Key returns the stable key of the tool or the skill. +func (attachment Attachment) Key() string { + switch { + case attachment.Kind == AttachmentTool && attachment.Tool != nil: + return attachment.Tool.Key + case attachment.Kind == AttachmentSkill && attachment.Skill != nil: + return attachment.Skill.Key + default: + return "" + } +} + +// ID returns the manifest identity of the attachment in one project. +func (attachment Attachment) ID(projectKey string) ResourceID { + return ResourceID{Kind: Kind(attachment.Kind), ProjectKey: projectKey, LookupKey: attachment.Key()} +} + +// CanonicalAttachment removes the runtime version, local-only flags, and +// server-owned metadata. Two attachments with the same canonical form have +// the same content. +func CanonicalAttachment(attachment Attachment) Attachment { + attachment.Version = 0 + attachment.Upsert = false + if attachment.Tool != nil { + tool := *attachment.Tool + if len(tool.CustomParameters) == 0 { + tool.CustomParameters = nil + } + if len(tool.Tags) == 0 { + tool.Tags = nil + } else { + tool.Tags = slices.Sorted(slices.Values(tool.Tags)) + } + attachment.Tool = &tool + } + if attachment.Skill != nil { + skill := *attachment.Skill + skill.Name = "" + attachment.Skill = &skill + } + return attachment +} + +// SameAttachmentContent reports whether two attachments have the same +// canonical content. +func SameAttachmentContent(left, right Attachment) bool { + leftJSON, _ := json.Marshal(CanonicalAttachment(left)) + rightJSON, _ := json.Marshal(CanonicalAttachment(right)) + return string(leftJSON) == string(rightJSON) +} diff --git a/internal/sync/bootstrap/bootstrap.go b/internal/sync/bootstrap/bootstrap.go index 63ecc58c..3a76eb84 100644 --- a/internal/sync/bootstrap/bootstrap.go +++ b/internal/sync/bootstrap/bootstrap.go @@ -1,8 +1,9 @@ +// Package bootstrap adds LaunchDarkly variations to the local workspace. The +// first add creates the .launchdarkly directory. package bootstrap import ( "cmp" - "encoding/json" "errors" "fmt" "io" @@ -18,24 +19,25 @@ import ( syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" ) -// Catalog lists projects and configs available for bootstrap. +// Catalog finds the projects, configs, and variations to add. type Catalog interface { - SearchProjects(query string, limit, offset int) (syncapi.Page[syncapi.Project], error) - SearchConfigs(projectKey, query string, modes []syncdomain.VariationMode, limit, offset int) (syncapi.Page[syncapi.Config], error) + syncinteractive.ProjectSearcher + syncinteractive.ConfigSearcher Config(projectKey, configKey string) (syncapi.Config, error) } +// AttachmentReader reads the tools and skills that a variation uses. type AttachmentReader interface { ReadAttachment(projectKey string, kind syncdomain.AttachmentKind, key string) (syncdomain.Attachment, error) } -// ManifestStore persists the synchronization baseline after local files are written. +// ManifestStore reads and writes the sync baseline. type ManifestStore interface { Load(projectKeys []string) (syncmanifest.Manifest, error) Update(previous, next syncmanifest.Manifest) (syncmanifest.Manifest, error) } -// Options contains the dependencies and streams for one bootstrap flow. +// Options are the dependencies and the input of one add. type Options struct { Catalog Catalog Attachments AttachmentReader @@ -43,133 +45,96 @@ type Options struct { Manifest ManifestStore Input io.Reader Output io.Writer - Initial bool - DryRun bool - Selections []syncdomain.ResourceID - NoInput bool + // Initial is true when the workspace has no .launchdarkly directory. + Initial bool + DryRun bool + // Selections are the variations to add. If it is empty, Run asks the user. + Selections []syncdomain.ResourceID + NoInput bool } -// Run resolves selected prompt variations and writes their local wrappers. +// Run writes the files of the selected variations and records their baseline. func Run(options Options) error { + var files []synclocal.VariationFile + var err error if len(options.Selections) != 0 { - files, err := selectVariationFilesByID(options) - if err != nil { + files, err = selectedVariations(options) + } else { + if err := syncinteractive.RequireTerminal( + options.Input, options.Output, options.NoInput, "variation selectors", "prompt selection", + ); err != nil { return err } - return finishSelection(options, files) - } - if options.NoInput { - return fmt.Errorf("variation selectors are required with --no-input") - } - if !syncinteractive.StreamsAreTerminal(options.Input, options.Output) { - return fmt.Errorf("interactive prompt selection requires a terminal; use selectors with --no-input") + var canceled bool + files, canceled, err = promptForVariations(options) + if canceled { + return nil + } } - - files, canceled, err := selectVariationFiles(options) if err != nil { return err } - if canceled { - return nil - } return finishSelection(options, files) } -func selectVariationFilesByID(options Options) ([]synclocal.VariationFile, error) { +// selectedVariations reads each variation that the user named. +func selectedVariations(options Options) ([]synclocal.VariationFile, error) { configs := make(map[string]syncapi.Config) seen := make(map[syncdomain.ResourceID]struct{}, len(options.Selections)) files := make([]synclocal.VariationFile, 0, len(options.Selections)) for _, selection := range options.Selections { - if selection.Kind != syncdomain.KindVariation { - return nil, fmt.Errorf("cannot add %s resource", selection.Kind) + configKey, variationKey, err := selection.VariationKeys() + if err != nil { + return nil, err } if _, duplicate := seen[selection]; duplicate { - return nil, fmt.Errorf("variation %s/%s was selected more than once", selection.ProjectKey, selection.LookupKey) + return nil, fmt.Errorf("variation %s was selected more than once", selection) } seen[selection] = struct{}{} - configKey, variationKey, ok := strings.Cut(selection.LookupKey, "/") - if !ok { - return nil, fmt.Errorf("invalid variation %q", selection.LookupKey) - } - cacheKey := selection.ProjectKey + "/" + configKey - config, ok := configs[cacheKey] + configID := selection.ProjectKey + "/" + configKey + config, ok := configs[configID] if !ok { - var err error - config, err = options.Catalog.Config(selection.ProjectKey, configKey) - if err != nil { + if config, err = options.Catalog.Config(selection.ProjectKey, configKey); err != nil { return nil, err } - configs[cacheKey] = config + configs[configID] = config } - - variationIndex := slices.IndexFunc(config.Variations, func(variation syncdomain.Variation) bool { + index := slices.IndexFunc(config.Variations, func(variation syncdomain.Variation) bool { return variation.Key == variationKey }) - if variationIndex < 0 { + if index < 0 { return nil, fmt.Errorf( - "variation %q does not exist in config %q in project %q", - variationKey, - configKey, - selection.ProjectKey, + "variation %q does not exist in config %q in project %q", variationKey, configKey, selection.ProjectKey, ) } + exists, err := options.Store.VariationExists(selection.ProjectKey, configKey, variationKey) if err != nil { return nil, err } if exists { - return nil, fmt.Errorf("variation %s/%s is already synced", selection.ProjectKey, selection.LookupKey) + return nil, fmt.Errorf("variation %s is already synced", selection) } - variation := config.Variations[variationIndex] - if err := hydrateAttachments(options.Attachments, selection.ProjectKey, &variation); err != nil { + file, err := newVariationFile(options.Attachments, selection.ProjectKey, configKey, config.Variations[index]) + if err != nil { return nil, err } - files = append(files, synclocal.VariationFile{ - ProjectKey: selection.ProjectKey, - ConfigKey: configKey, - Upsert: true, - Variation: variation, - }) + files = append(files, file) } return files, nil } -// selectVariationFiles guides the user from project to config to variations -// and converts the selections into local wrapper definitions. -func selectVariationFiles(options Options) ([]synclocal.VariationFile, bool, error) { - project, canceled, err := syncinteractive.SearchSelect(syncinteractive.SearchOptions[syncapi.Project]{ - Input: options.Input, - Output: options.Output, - SearchTitle: "Search LaunchDarkly projects", - SearchPlaceholder: "Project name or key", - SelectTitle: "Choose a LaunchDarkly project", - ItemName: "projects", - Fetch: func(query string, limit, offset int) ([]syncapi.Project, int, error) { - page, err := options.Catalog.SearchProjects(query, limit, offset) - return page.Items, page.TotalCount, err - }, - Choice: projectChoice, - }) +// promptForVariations asks the user for a project, a config, and one or more +// variations. 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 } - - config, canceled, err := syncinteractive.SearchSelect(syncinteractive.SearchOptions[syncapi.Config]{ - Input: options.Input, - Output: options.Output, - SearchTitle: "Search LaunchDarkly configs", - SearchPlaceholder: "Config name or key", - SelectTitle: "Choose a config", - ItemName: "configs", - Fetch: func(query string, limit, offset int) ([]syncapi.Config, int, error) { - page, err := options.Catalog.SearchConfigs(project.Key, query, nil, limit, offset) - return page.Items, page.TotalCount, err - }, - Choice: configChoice, - }) + config, canceled, err := syncinteractive.SelectConfig(options.Input, options.Output, options.Catalog, project.Key, nil) if err != nil || canceled { return nil, canceled, err } @@ -177,18 +142,10 @@ func selectVariationFiles(options Options) ([]synclocal.VariationFile, bool, err return nil, false, fmt.Errorf("config %q has no prompt variations", config.Key) } - choices, existingCount, err := variationChoices( - options.Store, - project.Key, - config, - ) - if err != nil { + choices, existingCount, err := variationChoices(options.Store, project.Key, config) + if err != nil || len(choices) == 0 { return nil, false, err } - if len(choices) == 0 { - return nil, false, nil - } - action := "write" if options.DryRun { action = "preview" @@ -198,11 +155,7 @@ func selectVariationFiles(options Options) ([]synclocal.VariationFile, bool, err description += fmt.Sprintf(" %d already synced variations are omitted.", existingCount) } variations, canceled, err := syncinteractive.MultiSelect( - options.Input, - options.Output, - "Select prompt variations", - description, - choices, + options.Input, options.Output, "Select prompt variations", description, choices, ) if err != nil || canceled { return nil, canceled, err @@ -210,41 +163,17 @@ func selectVariationFiles(options Options) ([]synclocal.VariationFile, bool, err files := make([]synclocal.VariationFile, 0, len(variations)) for _, variation := range variations { - if err := hydrateAttachments(options.Attachments, project.Key, &variation); err != nil { + file, err := newVariationFile(options.Attachments, project.Key, config.Key, variation) + if err != nil { return nil, false, err } - files = append(files, synclocal.VariationFile{ - ProjectKey: project.Key, - ConfigKey: config.Key, - Upsert: true, - Variation: variation, - }) + files = append(files, file) } return files, false, nil } -func hydrateAttachments(reader AttachmentReader, projectKey string, variation *syncdomain.Variation) error { - for index := range variation.Tools { - attachment, err := reader.ReadAttachment(projectKey, syncdomain.AttachmentTool, variation.Tools[index].Key) - if err != nil { - return err - } - variation.Tools[index].Version = attachment.Version - variation.SetAttachment(attachment) - } - for index := range variation.Skills { - attachment, err := reader.ReadAttachment(projectKey, syncdomain.AttachmentSkill, variation.Skills[index].Key) - if err != nil { - return err - } - variation.Skills[index].Version = attachment.Version - variation.SetAttachment(attachment) - } - return variation.NormalizeAttachments() -} - -// variationChoices returns unsynchronized variations in stable display order -// and separately counts variations that already have local wrappers. +// variationChoices returns the variations that are not synced yet, by name. +// It also returns the number of variations that are already synced. func variationChoices( store synclocal.Store, projectKey string, @@ -275,34 +204,33 @@ func variationChoices( return choices, existingCount, nil } -// projectChoice keeps the readable project name above its stable key. -func projectChoice(project syncapi.Project) syncinteractive.Choice[syncapi.Project] { - return syncinteractive.Choice[syncapi.Project]{ - Title: project.Name, Description: "Key: " + project.Key, Value: project, - } -} - -// configChoice includes both the stable config identity and its mode. -func configChoice(config syncapi.Config) syncinteractive.Choice[syncapi.Config] { - return syncinteractive.Choice[syncapi.Config]{ - Title: config.Name, Description: fmt.Sprintf("Key: %s · Mode: %s", config.Key, config.Mode), Value: config, +// newVariationFile reads the tools and skills of a LaunchDarkly variation, so +// that the add also writes their files. +func newVariationFile( + reader AttachmentReader, + projectKey, configKey string, + variation syncdomain.Variation, +) (synclocal.VariationFile, error) { + err := variation.HydrateAttachments(func(kind syncdomain.AttachmentKind, key string) (syncdomain.Attachment, error) { + return reader.ReadAttachment(projectKey, kind, key) + }) + if err != nil { + return synclocal.VariationFile{}, err } + return synclocal.VariationFile{ProjectKey: projectKey, ConfigKey: configKey, Upsert: true, Variation: variation}, nil } -// finishSelection validates the selected variations, renders dry-run previews, -// or commits the wrappers and their manifest fingerprints together. +// finishSelection previews the files, or writes them and records their +// baseline. The manifest update comes last, so that the manifest never +// tracks a file that is not on disk. If the baseline or the update fails, +// the files that this add created are removed. func finishSelection(options Options, files []synclocal.VariationFile) error { if len(files) == 0 { writeNoChangeSummary(options.Output, options.DryRun) return nil } if options.DryRun { - for _, file := range files { - if err := syncdomain.ValidateDirectAPIVariation(file.Variation); err != nil { - return err - } - } - previews, err := options.Store.RenderResources(files) + previews, err := options.Store.Render(files) if err != nil { return err } @@ -318,84 +246,68 @@ func finishSelection(options Options, files []synclocal.VariationFile) error { if err != nil { return err } - // Keep the loaded versions unchanged because Update uses them for - // optimistic concurrency. - previousManifest := manifest - manifest.Resources = slices.Clone(manifest.Resources) var creation synclocal.Creation if options.Initial { - creation, err = options.Store.BootstrapResources(files) + creation, err = options.Store.Bootstrap(files) } else { - creation, err = options.Store.AddResources(files) + creation, err = options.Store.Add(files) } if err != nil { return err } - if err := setManifestFromLocalResources(&manifest, options.Store, files); err != nil { - return errors.Join(err, options.Store.RollbackCreation(creation)) + next, err := recordCreatedVariations(manifest, options.Store, files) + if err == nil { + _, err = options.Manifest.Update(manifest, next) } - // The manifest is written last so it never claims a wrapper exists before - // that wrapper reaches disk. Roll back every file this operation created if - // persistence fails, including shared dependencies that did not exist before. - if _, err := options.Manifest.Update(previousManifest, manifest); err != nil { + if err != nil { return errors.Join(err, options.Store.RollbackCreation(creation)) } writeSummary(options.Output, options.Initial, len(creation.VariationPaths)) - return nil } -// setManifestFromLocalResources records the files that reached disk, including -// existing attachment files that creation preserved. -func setManifestFromLocalResources( - manifest *syncmanifest.Manifest, +// recordCreatedVariations returns a copy of the manifest with a baseline for +// each new variation. It reads the variations from disk, because Add keeps an +// existing tool or skill file, and the baseline must match that file. +func recordCreatedVariations( + manifest syncmanifest.Manifest, store synclocal.Store, files []synclocal.VariationFile, -) error { - selected := make(map[syncdomain.ResourceID]struct{}, len(files)) +) (syncmanifest.Manifest, error) { + created := make(map[syncdomain.ResourceID]struct{}, len(files)) for _, file := range files { - selected[syncdomain.ResourceID{ - Kind: syncdomain.KindVariation, - ProjectKey: file.ProjectKey, - LookupKey: file.ConfigKey + "/" + file.Variation.Key, - }] = struct{}{} + created[syncdomain.VariationID(file.ProjectKey, file.ConfigKey, file.Variation.Key)] = struct{}{} } - - resources, err := store.Compile() + variations, err := store.Compile() if err != nil { - return err + return syncmanifest.Manifest{}, err } - for _, resource := range resources { - id := syncdomain.ResourceID{ - Kind: resource.Kind, ProjectKey: resource.ProjectKey, LookupKey: resource.LookupKey, - } - if _, ok := selected[id]; !ok { + + next := manifest.Clone() + for _, variation := range variations { + id := variation.ID() + if _, ok := created[id]; !ok { continue } - var variation syncdomain.Variation - if err := json.Unmarshal(resource.Payload, &variation); err != nil { - return fmt.Errorf("decode local variation %q: %w", resource.LookupKey, err) - } - variation.Attachments = resource.Attachments - fingerprint, err := syncdomain.FingerprintVariation(resource.ProjectKey, resource.LookupKey, variation) + fingerprint, err := syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, variation.Variation) if err != nil { - return err + return syncmanifest.Manifest{}, err } - manifest.SetFingerprint(id, fingerprint) - if err := manifest.SetAttachmentsIfMissing(resource.ProjectKey, resource.Attachments); err != nil { - return err + next.SetFingerprint(id, fingerprint) + if err := next.SetAttachmentsIfMissing(id.ProjectKey, variation.Variation.Attachments); err != nil { + return syncmanifest.Manifest{}, err } - delete(selected, id) + delete(created, id) } - for id := range selected { - return fmt.Errorf("created variation %s/%s was not found", id.ProjectKey, id.LookupKey) + for id := range created { + return syncmanifest.Manifest{}, fmt.Errorf("created variation %s was not found", id) } - return nil + return next, nil } -// writeNoChangeSummary explains that every available variation is already local. +// writeNoChangeSummary reports that the config has no variation to add. func writeNoChangeSummary(output io.Writer, dryRun bool) { message := "No variations added; every variation in that config is already synced." if dryRun { @@ -404,46 +316,36 @@ func writeNoChangeSummary(output io.Writer, dryRun bool) { _ = syncconsole.New(output).Line(message) } -// writeSummary reports how many wrapper files were created. +// writeSummary reports how many variation files the add created. func writeSummary(output io.Writer, initial bool, count int) { verb := "Added" if initial { verb = "Bootstrapped" } - - resource := "variation file" + noun := "variation file" if count != 1 { - resource += "s" + noun += "s" } - - _ = syncconsole.New(output).Printf( - "%s %d %s in %s.\n", - verb, - count, - resource, - syncdomain.RootDir, - ) + _ = syncconsole.New(output).Printf("%s %d %s in %s.\n", verb, count, noun, syncdomain.RootDir) } -// writePreviews prints each dry-run file with clear boundaries and its target path. -func writePreviews(output io.Writer, previews []synclocal.RenderedVariationFile) { +// writePreviews prints each file that a dry run would create. +func writePreviews(output io.Writer, previews []synclocal.RenderedFile) { console := syncconsole.New(output) + const border = "============================================================" for index, preview := range previews { if index != 0 { _ = console.Line("") } - _ = console.Line("============================================================") + _ = console.Line(border) _ = console.Printf( - "File %d of %d\nWould create: %s\n", - index+1, - len(previews), - path.Join(syncdomain.RootDir, preview.Path), + "File %d of %d\nWould create: %s\n", index+1, len(previews), path.Join(syncdomain.RootDir, preview.Path), ) _ = console.Line("------------------------------------------------------------") _ = console.WriteBytes(preview.Content) if len(preview.Content) == 0 || preview.Content[len(preview.Content)-1] != '\n' { _ = console.Line("") } - _ = console.Line("============================================================") + _ = console.Line(border) } } diff --git a/internal/sync/bootstrap/bootstrap_test.go b/internal/sync/bootstrap/bootstrap_test.go index d561c447..357e15ea 100644 --- a/internal/sync/bootstrap/bootstrap_test.go +++ b/internal/sync/bootstrap/bootstrap_test.go @@ -2,7 +2,6 @@ package bootstrap import ( "bytes" - "encoding/json" "errors" "io" "os" @@ -254,10 +253,7 @@ func TestFinishSelectionFingerprintsExistingAttachmentContent(t *testing.T) { if resource.LookupKey != "config/second" { continue } - var variation syncdomain.Variation - require.NoError(t, json.Unmarshal(resource.Payload, &variation)) - variation.Attachments = resource.Attachments - expectedFingerprint, err = syncdomain.FingerprintVariation(resource.ProjectKey, resource.LookupKey, variation) + expectedFingerprint, err = syncdomain.FingerprintVariation(resource.ProjectKey, resource.LookupKey, resource.Variation) require.NoError(t, err) } require.NotEmpty(t, expectedFingerprint) @@ -388,7 +384,7 @@ func TestWriteSummary(t *testing.T) { func TestWritePreviewsPrintsEveryFile(t *testing.T) { var output bytes.Buffer - writePreviews(&output, []synclocal.RenderedVariationFile{ + writePreviews(&output, []synclocal.RenderedFile{ {Path: "project/configs/config/first.prompt.md", Content: []byte("first\n")}, {Path: "project/configs/config/second.prompt.md", Content: []byte("second\n")}, }) diff --git a/internal/sync/detach/detach.go b/internal/sync/detach/detach.go index 0e92c9a6..29ff51a9 100644 --- a/internal/sync/detach/detach.go +++ b/internal/sync/detach/detach.go @@ -1,12 +1,12 @@ +// Package detach stops syncing selected variations. It removes their local +// files and their baseline. LaunchDarkly keeps the variations. package detach import ( "errors" "fmt" "io" - "path" "slices" - "strings" syncdomain "github.com/launchdarkly/ldcli/internal/sync" syncconsole "github.com/launchdarkly/ldcli/internal/sync/console" @@ -15,199 +15,166 @@ import ( syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" ) -// Resource identifies one local or manifested resource that can be detached. -type Resource = syncdomain.ResourceID - +// ManifestStore reads and writes the sync baseline. type ManifestStore interface { Load(projectKeys []string) (syncmanifest.Manifest, error) Update(previous, next syncmanifest.Manifest) (syncmanifest.Manifest, error) } -// Options contains the local stores and streams used by detach. +// Options are the dependencies and the input of one detach. type Options struct { RepositoryRoot string Store synclocal.Store Manifest ManifestStore - ProjectKeys []string - Input io.Reader - Output io.Writer - Selections []Resource - NoInput bool + // ProjectKeys are the projects that have local files. + ProjectKeys []string + Input io.Reader + Output io.Writer + // Selections are the variations to detach. If it is empty, Run asks the user. + Selections []syncdomain.ResourceID + NoInput bool } -// Run resolves selected resources and removes their local sync state. +// Run detaches the selected variations. func Run(options Options) error { - projectKeys := append([]string(nil), options.ProjectKeys...) + projectKeys := slices.Clone(options.ProjectKeys) for _, selection := range options.Selections { projectKeys = append(projectKeys, selection.ProjectKey) } slices.Sort(projectKeys) projectKeys = slices.Compact(projectKeys) - resources, manifest, err := loadResources(options.RepositoryRoot, options.Manifest, projectKeys) + synced, manifest, err := loadResources(options.RepositoryRoot, options.Manifest, projectKeys) if err != nil { return err } - if len(resources) == 0 { - _ = syncconsole.New(options.Output).Line("No resources are currently synced.") + console := syncconsole.New(options.Output) + if len(synced) == 0 { + _ = console.Line("No resources are currently synced.") return nil } selected := options.Selections if len(selected) == 0 { - if options.NoInput { - return fmt.Errorf("variation selectors are required with --no-input") - } - if !syncinteractive.StreamsAreTerminal(options.Input, options.Output) { - return fmt.Errorf("interactive resource selection requires a terminal; use selectors with --no-input") - } - choices := make([]syncinteractive.Choice[Resource], 0, len(resources)) - for _, resource := range resources { - choices = append(choices, syncinteractive.Choice[Resource]{ - Title: resource.ProjectKey + "/" + resource.LookupKey, - Description: string(resource.Kind), - Value: resource, - }) - } var canceled bool - selected, canceled, err = syncinteractive.MultiSelect( - options.Input, - options.Output, - "Select resources to detach", - "Detached resources remain in LaunchDarkly.", - choices, - ) - if err != nil { + if selected, canceled, err = promptForResources(options, synced); err != nil || canceled { return err } - if canceled { - return nil - } - } else if err := validateSelections(resources, selected); err != nil { + } else if err := validateSelections(synced, selected); err != nil { return err } if err := detachResources(options, manifest, selected); err != nil { return err } - console := syncconsole.New(options.Output) _ = console.Line("Detached resources:") for _, resource := range selected { - _ = console.Printf("- %s %s/%s\n", resource.Kind, resource.ProjectKey, resource.LookupKey) + _ = console.Printf("- %s %s\n", resource.Kind, resource) } return nil } -func validateSelections(available, selected []Resource) error { - seen := make(map[Resource]struct{}, len(selected)) +// promptForResources asks the user to choose one or more synced variations. +// The bool result is true when the user cancels. +func promptForResources(options Options, synced []syncdomain.ResourceID) ([]syncdomain.ResourceID, bool, error) { + if err := syncinteractive.RequireTerminal( + options.Input, options.Output, options.NoInput, "variation selectors", "resource selection", + ); err != nil { + return nil, false, err + } + choices := make([]syncinteractive.Choice[syncdomain.ResourceID], 0, len(synced)) + for _, resource := range synced { + choices = append(choices, syncinteractive.Choice[syncdomain.ResourceID]{ + 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, + ) +} + +func validateSelections(synced, selected []syncdomain.ResourceID) error { + seen := make(map[syncdomain.ResourceID]struct{}, len(selected)) for _, selection := range selected { if _, duplicate := seen[selection]; duplicate { - return fmt.Errorf("variation %s/%s was selected more than once", selection.ProjectKey, selection.LookupKey) + return fmt.Errorf("variation %s was selected more than once", selection) } seen[selection] = struct{}{} - if !slices.Contains(available, selection) { - return fmt.Errorf("variation %s/%s is not synced", selection.ProjectKey, selection.LookupKey) + if !slices.Contains(synced, selection) { + return fmt.Errorf("variation %s is not synced", selection) } } return nil } -// loadResources returns the union of local wrappers and manifested resources. +// loadResources returns each variation that the manifest tracks or that has +// a local file, in identity order. func loadResources( repositoryRoot string, manifestStore ManifestStore, projectKeys []string, -) ([]Resource, syncmanifest.Manifest, error) { +) ([]syncdomain.ResourceID, syncmanifest.Manifest, error) { manifest, err := manifestStore.Load(projectKeys) if err != nil { return nil, syncmanifest.Manifest{}, err } + files, err := synclocal.SourceFiles(repositoryRoot) + if err != nil { + return nil, syncmanifest.Manifest{}, err + } - resources := make(map[Resource]struct{}, len(manifest.Resources)) + var synced []syncdomain.ResourceID for _, resource := range manifest.Resources { if resource.ResourceKind == syncdomain.KindVariation { - resources[resource.ID()] = struct{}{} + synced = append(synced, resource.ID()) } } - - files, err := synclocal.SourceFiles(repositoryRoot) - if err != nil { - return nil, syncmanifest.Manifest{}, err - } for _, file := range files { - resource, ok := resourceFromWrapperPath(file) - if ok { - resources[resource] = struct{}{} + if id, ok := synclocal.ParseManagedPath(file); ok && id.Kind == syncdomain.KindVariation { + synced = append(synced, id) } } - - result := make([]Resource, 0, len(resources)) - for resource := range resources { - result = append(result, resource) - } - slices.SortFunc(result, syncdomain.CompareResourceIDs) - return result, manifest, nil -} - -// resourceFromWrapperPath derives a variation identity without parsing its contents. -func resourceFromWrapperPath(file string) (Resource, bool) { - parts := strings.Split(file, "/") - if len(parts) != 5 || parts[0] != syncdomain.RootDir || parts[2] != "configs" || !strings.HasSuffix(parts[4], ".prompt.md") { - return Resource{}, false - } - variationKey := strings.TrimSuffix(parts[4], ".prompt.md") - if parts[1] == "" || parts[3] == "" || variationKey == "" { - return Resource{}, false - } - return Resource{Kind: syncdomain.KindVariation, ProjectKey: parts[1], LookupKey: path.Join(parts[3], variationKey)}, true + slices.SortFunc(synced, syncdomain.CompareResourceIDs) + return slices.Compact(synced), manifest, nil } -// detachResources removes selected resources from the manifest before deleting local wrappers. -func detachResources(options Options, original syncmanifest.Manifest, selected []Resource) error { - selectedSet := make(map[Resource]struct{}, len(selected)) - for _, resource := range selected { - selectedSet[resource] = struct{}{} - } +// detachResources removes the selected variations from the manifest, and then +// deletes their local files. If the delete fails, it restores the manifest. +func detachResources(options Options, original syncmanifest.Manifest, selected []syncdomain.ResourceID) error { + isSelected := func(id syncdomain.ResourceID) bool { return slices.Contains(selected, id) } - updated := syncmanifest.New() + next := syncmanifest.New() for _, resource := range original.Resources { - if _, detach := selectedSet[resource.ID()]; !detach { - updated.Resources = append(updated.Resources, resource) + if !isSelected(resource.ID()) { + next.Resources = append(next.Resources, resource) } } - if resources, err := synclocal.CompileWorkspace(options.RepositoryRoot); err == nil { - referenced := make(map[syncdomain.ResourceID]struct{}) - for _, resource := range resources { - if _, detach := selectedSet[syncdomain.ResourceID{ - Kind: resource.Kind, ProjectKey: resource.ProjectKey, LookupKey: resource.LookupKey, - }]; detach { - continue - } - for _, attachment := range resource.Attachments { - referenced[syncdomain.ResourceID{ - Kind: syncdomain.Kind(attachment.Kind), ProjectKey: resource.ProjectKey, LookupKey: attachment.Key(), - }] = struct{}{} - } - } - updated.RemoveUnreferencedAttachments(referenced) + // Stop tracking each tool and skill that only the detached variations use. + // If the workspace does not compile, keep every attachment baseline. + if variations, err := synclocal.CompileWorkspace(options.RepositoryRoot); err == nil { + remaining := slices.DeleteFunc(variations, func(variation syncdomain.SyncedResource) bool { + return isSelected(variation.ID()) + }) + next.RemoveUnusedAttachments(remaining) } - persisted, err := options.Manifest.Update(original, updated) + persisted, err := options.Manifest.Update(original, next) if err != nil { return err } + restoreManifest := func(cause error) error { + _, restoreErr := options.Manifest.Update(persisted, original) + return errors.Join(cause, restoreErr) + } var deletions []synclocal.VariationDeletion for _, resource := range selected { - if resource.Kind != syncdomain.KindVariation { - continue - } - configKey, variationKey, ok := strings.Cut(resource.LookupKey, "/") - if !ok || strings.Contains(variationKey, "/") { + configKey, variationKey, err := resource.VariationKeys() + if err != nil { continue } exists, err := options.Store.VariationExists(resource.ProjectKey, configKey, variationKey) if err != nil { - return errors.Join(err, restoreManifest(options.Manifest, persisted, original)) + return restoreManifest(err) } if exists { deletions = append(deletions, synclocal.VariationDeletion{ @@ -215,18 +182,8 @@ func detachResources(options Options, original syncmanifest.Manifest, selected [ }) } } - if _, err := options.Store.DeleteVariations(deletions); err != nil { - return errors.Join(err, restoreManifest(options.Manifest, persisted, original)) - } - if err := options.Store.RemoveEmptyDirectories(); err != nil { - return err + return restoreManifest(err) } return nil } - -// restoreManifest restores the manifest when local wrapper deletion fails. -func restoreManifest(store ManifestStore, current, original syncmanifest.Manifest) error { - _, err := store.Update(current, original) - return err -} diff --git a/internal/sync/detach/detach_test.go b/internal/sync/detach/detach_test.go index c5ac79fc..fbbcd257 100644 --- a/internal/sync/detach/detach_test.go +++ b/internal/sync/detach/detach_test.go @@ -41,7 +41,7 @@ func TestLoadResourcesUnionsLocalAndManifestResources(t *testing.T) { resources, _, err := loadResources(root, manifestStore, []string{"project"}) require.NoError(t, err) - assert.Equal(t, []Resource{ + assert.Equal(t, []syncdomain.ResourceID{ {Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/local"}, {Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/manifest-only"}, }, resources) @@ -75,11 +75,11 @@ func TestDetachResourcesPrunesUnreferencedAttachmentManifestEntries(t *testing.T } require.NoError(t, manifestStore.Write(original)) - resource := Resource{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/prompt"} + resource := syncdomain.ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/prompt"} err = detachResources( Options{RepositoryRoot: root, Store: store, Manifest: manifestStore}, original, - []Resource{resource}, + []syncdomain.ResourceID{resource}, ) require.NoError(t, err) @@ -111,8 +111,8 @@ func TestDetachResourcesRemovesWrapperAndManifestButKeepsReferencedFile(t *testi } require.NoError(t, manifestStore.Write(original)) - resource := Resource{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/prompt"} - err = detachResources(Options{Store: store, Manifest: manifestStore}, original, []Resource{resource}) + resource := syncdomain.ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/prompt"} + err = detachResources(Options{Store: store, Manifest: manifestStore}, original, []syncdomain.ResourceID{resource}) require.NoError(t, err) exists, err := store.VariationExists("project", "config", "prompt") @@ -137,8 +137,8 @@ func TestDetachResourcesRemovesManifestEntryWhenWrapperWasAlreadyDeleted(t *test } require.NoError(t, manifestStore.Write(original)) - resource := Resource{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/deleted"} - err := detachResources(Options{Store: store, Manifest: manifestStore}, original, []Resource{resource}) + resource := syncdomain.ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/deleted"} + err := detachResources(Options{Store: store, Manifest: manifestStore}, original, []syncdomain.ResourceID{resource}) require.NoError(t, err) manifest, err := manifestStore.Load([]string{"project"}) @@ -154,12 +154,12 @@ func TestDetachResourcesDeletesUnreadableWrapper(t *testing.T) { store := synclocal.NewStore(root) manifestStore := newMemoryManifestStore() - resource := Resource{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/broken"} + resource := syncdomain.ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/broken"} err := detachResources( Options{Store: store, Manifest: manifestStore}, syncmanifest.New(), - []Resource{resource}, + []syncdomain.ResourceID{resource}, ) require.NoError(t, err) @@ -196,7 +196,7 @@ func TestRunUsesExplicitSelectionsWithoutTerminal(t *testing.T) { ProjectKey: "project", ConfigKey: "config", Variation: testVariation("prompt"), }}) require.NoError(t, err) - selection := Resource{ + selection := syncdomain.ResourceID{ Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/prompt", } manifestStore := newMemoryManifestStore() @@ -207,7 +207,7 @@ func TestRunUsesExplicitSelectionsWithoutTerminal(t *testing.T) { Manifest: manifestStore, Input: bytes.NewBuffer(nil), Output: bytes.NewBuffer(nil), - Selections: []Resource{selection}, + Selections: []syncdomain.ResourceID{selection}, NoInput: true, }) diff --git a/internal/sync/fingerprint.go b/internal/sync/fingerprint.go index 65efb0af..de9f1045 100644 --- a/internal/sync/fingerprint.go +++ b/internal/sync/fingerprint.go @@ -9,53 +9,22 @@ import ( "slices" ) +// The schema names are part of every fingerprint. Change one only to force a +// new baseline for every workspace. const ( variationFingerprintSchema = "launchdarkly.config.variation/v1" attachmentFingerprintSchema = "launchdarkly.config.attachment/v1" ) -// FingerprintVariation returns a stable fingerprint for the variation fields -// supported by the existing config variation APIs. +// FingerprintVariation returns a stable hash of the variation content that +// sync owns. Two variations with the same behavior have the same fingerprint. func FingerprintVariation(projectKey, lookupKey string, variation Variation) (string, error) { - normalized := variation - // A value copy still shares slice backing arrays; clone before canonical - // normalization so fingerprinting never mutates the caller's variation. - normalized.Tools = append([]AttachmentRef(nil), variation.Tools...) - normalized.Skills = append([]AttachmentRef(nil), variation.Skills...) - normalized.Attachments = append([]Attachment(nil), variation.Attachments...) - if err := normalized.NormalizeAttachments(); err != nil { + if err := variation.Validate(); err != nil { return "", err } - if err := validateDirectAPIVariationFields(normalized); err != nil { - return "", err - } - for index := range normalized.Attachments { - normalized.Attachments[index] = CanonicalAttachment(normalized.Attachments[index]) - } - for index := range normalized.Tools { - normalized.Tools[index].Version = 0 - } - for index := range normalized.Skills { - normalized.Skills[index].Version = 0 - } - normalized.Model = normalizeModelForFingerprint(normalized.Model) - if len(normalized.Messages) == 0 { - normalized.Messages = nil - } - switch normalized.Mode { - case VariationModeAgent: - normalized.Instructions = NormalizePromptText(normalized.Instructions) - normalized.Messages = nil - case VariationModeCompletion: - normalized.Instructions = "" - // Clone messages before normalizing their text to preserve caller state. - normalized.Messages = append([]Message(nil), normalized.Messages...) - for index := range normalized.Messages { - normalized.Messages[index].Content = NormalizePromptText(normalized.Messages[index].Content) - } - } - value := struct { + canonical := variation.canonical() + return fingerprint("variation", struct { Schema string `json:"schema"` ResourceKind Kind `json:"resourceKind"` ProjectKey string `json:"projectKey"` @@ -67,24 +36,22 @@ func FingerprintVariation(projectKey, lookupKey string, variation Variation) (st ResourceKind: KindVariation, ProjectKey: projectKey, LookupKey: lookupKey, - Variation: normalized, - Attachments: normalized.Attachments, - } - - return fingerprint(value, "variation") + Variation: canonical, + Attachments: canonical.Attachments, + }) } -// FingerprintAttachment returns the stable baseline for one project-scoped -// tool or skill, excluding runtime version and file-only metadata. +// FingerprintAttachment returns a stable hash of the content of one tool or +// skill in a project. The runtime version does not change the hash. func FingerprintAttachment(projectKey string, attachment Attachment) (string, error) { - if attachment.Kind != AttachmentTool && attachment.Kind != AttachmentSkill { + if !attachment.Kind.Valid() { return "", fmt.Errorf("unsupported attachment kind %q", attachment.Kind) } if attachment.Key() == "" { return "", fmt.Errorf("%s key is required", attachment.Kind) } - value := struct { + return fingerprint(string(attachment.Kind), struct { Schema string `json:"schema"` ResourceKind Kind `json:"resourceKind"` ProjectKey string `json:"projectKey"` @@ -96,89 +63,71 @@ func FingerprintAttachment(projectKey string, attachment Attachment) (string, er ProjectKey: projectKey, LookupKey: attachment.Key(), Attachment: CanonicalAttachment(attachment), - } - return fingerprint(value, string(attachment.Kind)) + }) } -func fingerprint(value any, resource string) (string, error) { - canonical, err := json.Marshal(value) - if err != nil { - return "", fmt.Errorf("encode %s fingerprint: %w", resource, err) - } - sum := sha256.Sum256(canonical) - return "sha256:" + hex.EncodeToString(sum[:]), nil -} +// canonical returns a copy that keeps only the content that changes behavior. +// It does not change the slices or maps of the original variation. +func (variation Variation) canonical() Variation { + variation.Tools = unversionedRefs(variation.Tools) + variation.Skills = unversionedRefs(variation.Skills) + variation.Attachments = slices.Clone(variation.Attachments) + for index := range variation.Attachments { + variation.Attachments[index] = CanonicalAttachment(variation.Attachments[index]) + } + variation.SortAttachments() + variation.Model = canonicalModel(variation.Model) -// CanonicalAttachment removes runtime versions, API-only metadata, and -// ordering differences before comparison or fingerprinting. -func CanonicalAttachment(attachment Attachment) Attachment { - attachment.Version = 0 - attachment.Upsert = false - if attachment.Tool != nil { - tool := *attachment.Tool - if len(tool.CustomParameters) == 0 { - tool.CustomParameters = nil + switch variation.Mode { + case VariationModeAgent: + variation.Instructions = NormalizePromptText(variation.Instructions) + variation.Messages = nil + case VariationModeCompletion: + variation.Instructions = "" + messages := make([]Message, 0, len(variation.Messages)) + for _, message := range variation.Messages { + messages = append(messages, Message{Role: message.Role, Content: NormalizePromptText(message.Content)}) } - if len(tool.Tags) == 0 { - tool.Tags = nil - } else { - // Clone tags before sorting so canonicalization preserves caller order. - tool.Tags = append([]string(nil), tool.Tags...) - slices.Sort(tool.Tags) + variation.Messages = nil + if len(messages) != 0 { + variation.Messages = messages } - attachment.Tool = &tool } - if attachment.Skill != nil { - // Name is catalog metadata and is not editable from the local skill file. - skill := *attachment.Skill - skill.Name = "" - attachment.Skill = &skill - } - return attachment + return variation } -// normalizeModelForFingerprint removes only defaults that the variation API -// adds without changing model behavior. Other empty objects remain meaningful. -func normalizeModelForFingerprint(model map[string]any) map[string]any { - if len(model) == 0 { +// unversionedRefs copies references without their runtime version. +func unversionedRefs(refs []AttachmentRef) []AttachmentRef { + if refs == nil { return nil } + unversioned := make([]AttachmentRef, len(refs)) + for index, ref := range refs { + unversioned[index] = AttachmentRef{Key: ref.Key} + } + return unversioned +} - normalized := maps.Clone(model) +// canonicalModel removes the empty "parameters" and "custom" objects that the +// variation API adds. Other empty objects can change behavior, so they remain. +func canonicalModel(model map[string]any) map[string]any { + model = maps.Clone(model) for _, key := range []string{"parameters", "custom"} { - if value, ok := normalized[key].(map[string]any); ok && len(value) == 0 { - delete(normalized, key) + if value, ok := model[key].(map[string]any); ok && len(value) == 0 { + delete(model, key) } } - if len(normalized) == 0 { + if len(model) == 0 { return nil } - return normalized -} - -// ValidateDirectAPIVariation rejects fields that the existing variation APIs -// cannot round-trip without the sync endpoints. -func ValidateDirectAPIVariation(variation Variation) error { - if err := validateDirectAPIVariationFields(variation); err != nil { - return err - } - if err := normalizeAttachmentRefs(AttachmentTool, variation.Tools); err != nil { - return err - } - if err := normalizeAttachmentRefs(AttachmentSkill, variation.Skills); err != nil { - return err - } - return validateAttachmentMode(variation) + return model } -func validateDirectAPIVariationFields(variation Variation) error { - switch { - case !variation.Mode.Valid(): - return fmt.Errorf("unsupported variation mode %q", variation.Mode) - case variation.Key == "": - return fmt.Errorf("variation key is required") - case variation.Name == "": - return fmt.Errorf("variation name is required") +func fingerprint(resource string, value any) (string, error) { + canonical, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("encode %s fingerprint: %w", resource, err) } - return nil + sum := sha256.Sum256(canonical) + return "sha256:" + hex.EncodeToString(sum[:]), nil } diff --git a/internal/sync/fingerprint_golden_test.go b/internal/sync/fingerprint_golden_test.go new file mode 100644 index 00000000..23be6ff7 --- /dev/null +++ b/internal/sync/fingerprint_golden_test.go @@ -0,0 +1,52 @@ +package sync + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// LaunchDarkly stores these fingerprints in remote manifests. If one of these +// values changes, every existing workspace reports drift after an upgrade. +func TestFingerprintsAreStableAcrossReleases(t *testing.T) { + description := "Search documentation" + tool := Attachment{ + Kind: AttachmentTool, Version: 4, + Tool: &Tool{ + Key: "search", Description: &description, + Schema: map[string]any{"type": "object"}, Tags: []string{"b", "a"}, + }, + } + skill := Attachment{ + Kind: AttachmentSkill, Version: 2, + Skill: &Skill{Key: "support", Name: "Support", Description: "Guidance", Markdown: "# Support\n"}, + } + agent := Variation{ + Mode: VariationModeAgent, Key: "default", Name: "Default", + Instructions: " Help the user.\r\n", + ModelConfigKey: "claude", ModelConfigVersion: 3, + Model: map[string]any{"modelName": "claude", "parameters": map[string]any{}}, + OutputFormat: map[string]any{"type": "json"}, + Tools: []AttachmentRef{{Key: "search", Version: 4}}, + Skills: []AttachmentRef{{Key: "support", Version: 2}}, + Attachments: []Attachment{skill, tool}, + } + completion := Variation{ + Mode: VariationModeCompletion, Key: "chat", Name: "Chat", + Messages: []Message{{Role: "system", Content: "Be concise."}, {Role: "user", Content: "Hi"}}, + } + + agentFingerprint, err := FingerprintVariation("project", "config/default", agent) + require.NoError(t, err) + completionFingerprint, err := FingerprintVariation("project", "config/chat", completion) + require.NoError(t, err) + toolFingerprint, err := FingerprintAttachment("project", tool) + require.NoError(t, err) + skillFingerprint, err := FingerprintAttachment("project", skill) + require.NoError(t, err) + + require.Equal(t, "sha256:e764603e2fd4ff3ee1b16a85866c72d693e70f7d343f43e984d193ebebd206cb", agentFingerprint) + require.Equal(t, "sha256:87c2a750ed78d8eb20ef28ce535057c7abc9691b7d822db4e4470839e30df928", completionFingerprint) + require.Equal(t, "sha256:cadfcd880d050ddd8334d1b9ce22f08c563c85cb05910be95c22885273db916e", toolFingerprint) + require.Equal(t, "sha256:b1daa2342cf1945b988512e7957f47faf933c4da026260d183dfe4eb57fba7f7", skillFingerprint) +} diff --git a/internal/sync/fingerprint_test.go b/internal/sync/fingerprint_test.go index 9761a8b7..b0fdb6b0 100644 --- a/internal/sync/fingerprint_test.go +++ b/internal/sync/fingerprint_test.go @@ -283,16 +283,16 @@ func TestFingerprintAttachmentTracksCanonicalContentOnly(t *testing.T) { require.NotEqual(t, originalSkill, updatedSkill) } -func TestDirectAPIVariationSupportsVersionAndOutputFormat(t *testing.T) { +func TestVariationSupportsVersionAndOutputFormat(t *testing.T) { base := Variation{Mode: VariationModeAgent, Key: "default", Name: "Default"} withVersion := base withVersion.ModelConfigVersion = 3 - require.NoError(t, ValidateDirectAPIVariation(withVersion)) + require.NoError(t, withVersion.Validate()) withOutput := base withOutput.OutputFormat = map[string]any{"type": "json"} - require.NoError(t, ValidateDirectAPIVariation(withOutput)) + require.NoError(t, withOutput.Validate()) baseFingerprint, err := FingerprintVariation("project", "config/default", base) require.NoError(t, err) @@ -301,11 +301,11 @@ func TestDirectAPIVariationSupportsVersionAndOutputFormat(t *testing.T) { require.NotEqual(t, baseFingerprint, outputFingerprint) } -func TestValidateDirectAPIVariationRejectsSkillsForCompletionMode(t *testing.T) { +func TestValidateRejectsSkillsForCompletionMode(t *testing.T) { variation := Variation{ Mode: VariationModeCompletion, Key: "default", Name: "Default", Skills: []AttachmentRef{{Key: "support"}}, } - require.ErrorContains(t, ValidateDirectAPIVariation(variation), "skills can only be attached to agent-mode configs") + require.ErrorContains(t, variation.Validate(), "skills can only be attached to agent-mode configs") } diff --git a/internal/sync/interactive/catalog.go b/internal/sync/interactive/catalog.go new file mode 100644 index 00000000..d2076deb --- /dev/null +++ b/internal/sync/interactive/catalog.go @@ -0,0 +1,70 @@ +package interactive + +import ( + "fmt" + "io" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" +) + +// ProjectSearcher searches the LaunchDarkly projects. +type ProjectSearcher interface { + SearchProjects(query string, limit, offset int) (syncapi.Page[syncapi.Project], error) +} + +// ConfigSearcher searches the configs in a project. +type ConfigSearcher interface { + SearchConfigs(projectKey, query string, modes []syncdomain.VariationMode, limit, offset int) (syncapi.Page[syncapi.Config], error) +} + +// SelectProject asks the user to choose a project. The bool result is true +// when the user cancels. +func SelectProject(input io.Reader, output io.Writer, catalog ProjectSearcher) (syncapi.Project, bool, error) { + return SearchSelect(SearchOptions[syncapi.Project]{ + Input: input, + Output: output, + SearchTitle: "Search LaunchDarkly projects", + SearchPlaceholder: "Project name or key", + SelectTitle: "Choose a LaunchDarkly project", + ItemName: "projects", + Fetch: func(query string, limit, offset int) ([]syncapi.Project, int, error) { + page, err := catalog.SearchProjects(query, limit, offset) + return page.Items, page.TotalCount, err + }, + Choice: func(project syncapi.Project) Choice[syncapi.Project] { + return Choice[syncapi.Project]{Title: project.Name, Description: "Key: " + project.Key, Value: project} + }, + }) +} + +// SelectConfig asks the user to choose a config in a project. If modes is +// not empty, the list has only configs with one of the modes. The bool +// result is true when the user cancels. +func SelectConfig( + input io.Reader, + output io.Writer, + catalog ConfigSearcher, + projectKey string, + modes []syncdomain.VariationMode, +) (syncapi.Config, bool, error) { + return SearchSelect(SearchOptions[syncapi.Config]{ + Input: input, + Output: output, + SearchTitle: "Search LaunchDarkly configs", + SearchPlaceholder: "Config name or key", + SelectTitle: "Choose a config", + ItemName: "configs", + Fetch: func(query string, limit, offset int) ([]syncapi.Config, int, error) { + page, err := catalog.SearchConfigs(projectKey, query, modes, limit, offset) + return page.Items, page.TotalCount, err + }, + Choice: func(config syncapi.Config) Choice[syncapi.Config] { + return Choice[syncapi.Config]{ + Title: config.Name, + Description: fmt.Sprintf("Key: %s · Mode: %s", config.Key, config.Mode), + Value: config, + } + }, + }) +} diff --git a/internal/sync/interactive/terminal.go b/internal/sync/interactive/terminal.go index 0a8c96ca..813616d8 100644 --- a/internal/sync/interactive/terminal.go +++ b/internal/sync/interactive/terminal.go @@ -1,16 +1,31 @@ -// Package interactive contains shared helpers for terminal-based sync flows. +// Package interactive has the terminal forms and selectors that the sync +// commands use when the user does not give every argument. package interactive import ( + "fmt" "io" "os" "golang.org/x/term" ) -// StreamsAreTerminal reports whether both interactive streams are terminals. +// StreamsAreTerminal reports whether both streams are terminals. func StreamsAreTerminal(input io.Reader, output io.Writer) bool { in, inputIsFile := input.(*os.File) out, outputIsFile := output.(*os.File) return inputIsFile && outputIsFile && term.IsTerminal(int(in.Fd())) && term.IsTerminal(int(out.Fd())) } + +// RequireTerminal returns an error if the command cannot ask the user for +// input. The error names the arguments that replace the interactive input +// and the activity that needs a terminal, for example "prompt selection". +func RequireTerminal(input io.Reader, output io.Writer, noInput bool, arguments, activity string) error { + if noInput { + return fmt.Errorf("%s are required with --no-input", arguments) + } + if !StreamsAreTerminal(input, output) { + return fmt.Errorf("interactive %s requires a terminal; use %s with --no-input", activity, arguments) + } + return nil +} diff --git a/internal/sync/link/link.go b/internal/sync/link/link.go index c5eeeba4..777999a2 100644 --- a/internal/sync/link/link.go +++ b/internal/sync/link/link.go @@ -1,12 +1,16 @@ +// Package link creates a variation whose prompt lives in an external file in +// the repository. The variation file stores a reference to that file. package link import ( "bytes" + "cmp" "errors" "fmt" "io" "os" "path/filepath" + "slices" "strings" "unicode" @@ -21,15 +25,15 @@ import ( "github.com/launchdarkly/ldcli/internal/sync/reference/adapters" ) -// Catalog lists the LaunchDarkly resources required to link a prompt. +// Catalog finds the project, the config, and the model config of a new variation. type Catalog interface { - SearchProjects(query string, limit, offset int) (syncapi.Page[syncapi.Project], error) - SearchConfigs(projectKey, query string, modes []syncdomain.VariationMode, limit, offset int) (syncapi.Page[syncapi.Config], error) + syncinteractive.ProjectSearcher + syncinteractive.ConfigSearcher Config(projectKey, configKey string) (syncapi.Config, error) ModelConfigs(projectKey string) ([]syncapi.ModelConfig, error) } -// Options contains the dependencies and inputs for one link operation. +// Options are the dependencies and the input of one link. type Options struct { Catalog Catalog Store synclocal.Store @@ -39,12 +43,13 @@ type Options struct { Format string Input io.Reader Output io.Writer - Target *Target - NoInput bool + // Target is the new variation. If it is nil, Run asks the user. + Target *Target + NoInput bool } -// Target identifies a non-interactive link destination and any source -// metadata that is missing from the referenced file. +// Target is a new variation that the user named with flags. Name and Content +// replace values that the linked file does not have. type Target struct { Variation syncdomain.ResourceID ModelConfigKey string @@ -52,7 +57,7 @@ type Target struct { Content string } -// Selection is the LaunchDarkly destination selected for a linked prompt. +// Selection is the complete destination of a new linked variation. type Selection struct { Project syncapi.Project Config syncapi.Config @@ -61,6 +66,8 @@ type Selection struct { Name string } +// linkedPrompt is the external file. If the file has no prompt, content is +// the new prompt that link writes to the file. type linkedPrompt struct { reference synclocal.Reference parsed adapters.Prompt @@ -68,233 +75,175 @@ type linkedPrompt struct { content []byte } -// Run resolves a destination and creates the local linked variation wrapper. +// Run creates the linked variation file and returns its path. The path is +// empty when the user cancels. func Run(options Options) (string, error) { prompt, err := readLinkedPrompt(options) if err != nil { return "", err } - if options.Target != nil { - return linkToTarget(options, prompt, *options.Target) - } - if options.NoInput { - return "", fmt.Errorf("--to and --model-config-key are required with --no-input") - } - if !syncinteractive.StreamsAreTerminal(options.Input, options.Output) { - return "", fmt.Errorf("interactive prompt linking requires a terminal; use --to and --model-config-key with --no-input") - } - console := syncconsole.New(options.Output) - project, canceled, err := syncinteractive.SearchSelect(syncinteractive.SearchOptions[syncapi.Project]{ - Input: options.Input, - Output: options.Output, - SearchTitle: "Search LaunchDarkly projects", - SearchPlaceholder: "Project name or key", - SelectTitle: "Choose a LaunchDarkly project", - ItemName: "projects", - Fetch: func(query string, limit, offset int) ([]syncapi.Project, int, error) { - page, err := options.Catalog.SearchProjects(query, limit, offset) - return page.Items, page.TotalCount, err - }, - Choice: projectChoice, - }) - if err != nil || canceled { - return "", err + var selection Selection + var content string + if options.Target != nil { + selection, content, err = targetSelection(options, prompt, *options.Target) + } else { + if err := syncinteractive.RequireTerminal( + options.Input, options.Output, options.NoInput, "--to and --model-config-key", "prompt linking", + ); err != nil { + return "", err + } + var canceled bool + selection, content, canceled, err = promptForSelection(options, prompt) + if canceled { + return "", nil + } } - - var configModes []syncdomain.VariationMode - if prompt.parsed.Mode != "" { - configModes = []syncdomain.VariationMode{syncdomain.VariationMode(prompt.parsed.Mode)} - } - config, canceled, err := syncinteractive.SearchSelect(syncinteractive.SearchOptions[syncapi.Config]{ - Input: options.Input, - Output: options.Output, - SearchTitle: "Search LaunchDarkly configs", - SearchPlaceholder: "Config name or key", - SelectTitle: "Choose a config", - ItemName: "configs", - Fetch: func(query string, limit, offset int) ([]syncapi.Config, int, error) { - page, err := options.Catalog.SearchConfigs(project.Key, query, configModes, limit, offset) - return page.Items, page.TotalCount, err - }, - Choice: configChoice, - }) - if err != nil || canceled { + if err != nil { return "", err } - _ = console.Line("Loading model configs...") - modelConfigs, err := options.Catalog.ModelConfigs(project.Key) + prompt, err = addMissingPromptContent(prompt, selection.Config.Mode, selection.Key, selection.Name, content) if err != nil { return "", err } - modelConfig, canceled, err := syncinteractive.Select( - options.Input, - options.Output, - "Choose a model config", - modelConfigChoices(modelConfigs), - ) - if err != nil || canceled { - return "", err - } + return createLinkedPrompt(options, selection, prompt) +} - defaultKey := strings.TrimSuffix(filepath.Base(prompt.reference.File), filepath.Ext(prompt.reference.File)) - key := prompt.parsed.Key - var fields []huh.Field - if key == "" { - key = defaultKey - fields = append(fields, huh.NewInput(). - Title("Variation key"). - Value(&key). - Validate(validateDerivedKey)) - } - name := prompt.parsed.Name - if name == "" { - name = displayName(key) - fields = append(fields, huh.NewInput(). - Title("Variation name"). - Value(&name). - Validate(requiredValue("variation name"))) - } - var content string - if len(prompt.parsed.Messages) == 0 { - fields = append(fields, huh.NewText(). - Title("Prompt content"). - Lines(8). - Value(&content). - Validate(requiredValue("prompt content"))) +// targetSelection completes the destination that the user named with flags. +// It returns the prompt content to add when the linked file has none. +func targetSelection(options Options, prompt linkedPrompt, target Target) (Selection, string, error) { + configKey, variationKey, err := target.Variation.VariationKeys() + if err != nil { + return Selection{}, "", fmt.Errorf("link destination must be a variation: %w", err) } - if len(fields) != 0 { - canceled, err = syncinteractive.RunForm(options.Input, options.Output, fields...) - if err != nil || canceled { - return "", err - } + hasContent := len(prompt.parsed.Messages) != 0 + switch { + case prompt.parsed.Key != "" && prompt.parsed.Key != variationKey: + return Selection{}, "", fmt.Errorf("linked file key %q does not match destination key %q", prompt.parsed.Key, variationKey) + case target.Name != "" && strings.TrimSpace(target.Name) == "": + return Selection{}, "", errors.New("--name cannot be blank") + case target.Content != "" && strings.TrimSpace(target.Content) == "": + return Selection{}, "", errors.New("--content cannot be blank") + case prompt.parsed.Name != "" && target.Name != "" && prompt.parsed.Name != target.Name: + return Selection{}, "", fmt.Errorf("linked file name %q does not match --name %q", prompt.parsed.Name, target.Name) + case !hasContent && target.Content == "": + return Selection{}, "", errors.New("--content is required when the linked file has no prompt content") + case hasContent && target.Content != "": + return Selection{}, "", errors.New("--content cannot be used when the linked file already has prompt content") + } + + projectKey := target.Variation.ProjectKey + config, err := options.Catalog.Config(projectKey, configKey) + if err != nil { + return Selection{}, "", err } - - prompt, err = addMissingPromptContent(prompt, config.Mode, key, name, content) + modelConfigs, err := options.Catalog.ModelConfigs(projectKey) if err != nil { - return "", err + return Selection{}, "", err + } + index := slices.IndexFunc(modelConfigs, func(candidate syncapi.ModelConfig) bool { + return candidate.Key == target.ModelConfigKey + }) + if index < 0 { + return Selection{}, "", fmt.Errorf("model config %q was not found", target.ModelConfigKey) } - return createLinkedPrompt(options, Selection{ - Project: project, + name := cmp.Or(prompt.parsed.Name, target.Name, displayName(variationKey)) + return Selection{ + Project: syncapi.Project{Key: projectKey}, Config: config, - ModelConfig: modelConfig, - Key: key, + ModelConfig: modelConfigs[index], + Key: variationKey, Name: name, - }, prompt) + }, target.Content, nil } -func linkToTarget(options Options, prompt linkedPrompt, target Target) (string, error) { - if target.Variation.Kind != syncdomain.KindVariation { - return "", fmt.Errorf("link destination must be a variation") - } - configKey, variationKey, ok := strings.Cut(target.Variation.LookupKey, "/") - if !ok { - return "", fmt.Errorf("invalid variation %q", target.Variation.LookupKey) - } - if prompt.parsed.Key != "" && prompt.parsed.Key != variationKey { - return "", fmt.Errorf( - "linked file key %q does not match destination key %q", - prompt.parsed.Key, - variationKey, - ) +// promptForSelection asks the user for the destination, and for each value +// that the linked file does not have. The bool result is true when the user +// cancels. +func promptForSelection(options Options, prompt linkedPrompt) (Selection, string, bool, error) { + project, canceled, err := syncinteractive.SelectProject(options.Input, options.Output, options.Catalog) + if err != nil || canceled { + return Selection{}, "", canceled, err } - if target.Name != "" && strings.TrimSpace(target.Name) == "" { - return "", fmt.Errorf("--name cannot be blank") + var modes []syncdomain.VariationMode + if prompt.parsed.Mode != "" { + modes = []syncdomain.VariationMode{prompt.parsed.Mode} } - if target.Content != "" && strings.TrimSpace(target.Content) == "" { - return "", fmt.Errorf("--content cannot be blank") + config, canceled, err := syncinteractive.SelectConfig(options.Input, options.Output, options.Catalog, project.Key, modes) + if err != nil || canceled { + return Selection{}, "", canceled, err } - config, err := options.Catalog.Config(target.Variation.ProjectKey, configKey) - if err != nil { - return "", err - } - modelConfigs, err := options.Catalog.ModelConfigs(target.Variation.ProjectKey) + _ = syncconsole.New(options.Output).Line("Loading model configs...") + modelConfigs, err := options.Catalog.ModelConfigs(project.Key) if err != nil { - return "", err + return Selection{}, "", false, err } - var modelConfig syncapi.ModelConfig - for _, candidate := range modelConfigs { - if candidate.Key == target.ModelConfigKey { - modelConfig = candidate - break - } + choices := make([]syncinteractive.Choice[syncapi.ModelConfig], 0, len(modelConfigs)) + for _, modelConfig := range modelConfigs { + choices = append(choices, syncinteractive.Choice[syncapi.ModelConfig]{ + Title: modelConfig.Name, Description: "Key: " + modelConfig.Key, Value: modelConfig, + }) } - if modelConfig.Key == "" { - return "", fmt.Errorf("model config %q was not found", target.ModelConfigKey) + modelConfig, canceled, err := syncinteractive.Select(options.Input, options.Output, "Choose a model config", choices) + if err != nil || canceled { + return Selection{}, "", canceled, err } - name := prompt.parsed.Name - if target.Name != "" { - if name != "" && name != target.Name { - return "", fmt.Errorf("linked file name %q does not match --name %q", name, target.Name) - } - name = target.Name + // Ask only for the values that the linked file does not have. + key, name, content := prompt.parsed.Key, prompt.parsed.Name, "" + var fields []huh.Field + if key == "" { + key = strings.TrimSuffix(filepath.Base(prompt.reference.File), filepath.Ext(prompt.reference.File)) + fields = append(fields, huh.NewInput().Title("Variation key").Value(&key).Validate(validateDerivedKey)) } if name == "" { - name = displayName(variationKey) - } - if len(prompt.parsed.Messages) == 0 && target.Content == "" { - return "", fmt.Errorf("--content is required when the linked file has no prompt content") + name = displayName(key) + fields = append(fields, huh.NewInput().Title("Variation name").Value(&name).Validate(requiredValue("variation name"))) } - if len(prompt.parsed.Messages) != 0 && target.Content != "" { - return "", fmt.Errorf("--content cannot be used when the linked file already has prompt content") + if len(prompt.parsed.Messages) == 0 { + fields = append(fields, huh.NewText().Title("Prompt content").Lines(8).Value(&content). + Validate(requiredValue("prompt content"))) } - prompt, err = addMissingPromptContent(prompt, config.Mode, variationKey, name, target.Content) - if err != nil { - return "", err + if len(fields) != 0 { + if canceled, err := syncinteractive.RunForm(options.Input, options.Output, fields...); err != nil || canceled { + return Selection{}, "", canceled, err + } } - return createLinkedPrompt(options, Selection{ - Project: syncapi.Project{Key: target.Variation.ProjectKey}, - Config: config, - ModelConfig: modelConfig, - Key: variationKey, - Name: name, - }, prompt) + return Selection{Project: project, Config: config, ModelConfig: modelConfig, Key: key, Name: name}, content, false, nil } -// addMissingPromptContent renders entered content in memory so destination -// validation can finish before the referenced file is changed. +// addMissingPromptContent renders content as the new text of a linked file +// that has no prompt. It does not write the file. createLinkedPrompt writes +// it after every check passes. func addMissingPromptContent( prompt linkedPrompt, mode syncdomain.VariationMode, - key string, - name string, - content string, + key, name, content string, ) (linkedPrompt, error) { if len(prompt.parsed.Messages) != 0 { return prompt, nil } - variation := syncdomain.Variation{Mode: mode, Key: key, Name: name} if mode == syncdomain.VariationModeAgent { variation.Instructions = content } else { - variation.Messages = []syncdomain.Message{{Role: "system", Content: content}} + variation.Messages = []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: content}} } rendered, err := syncreference.Render(prompt.reference.Format, variation) if err != nil { return linkedPrompt{}, err } - prompt.content = rendered return prompt, nil } -// Create writes the wrapper for an already selected destination. -func Create(options Options, selection Selection) (string, error) { - prompt, err := readLinkedPrompt(options) - if err != nil { - return "", err - } - return createLinkedPrompt(options, selection, prompt) -} - -// createLinkedPrompt validates the destination, updates newly entered source -// content, and creates the wrapper that binds both sides. +// createLinkedPrompt checks the destination, writes new prompt content to the +// linked file, and creates the variation file. If the variation file fails, +// it restores the linked file. func createLinkedPrompt(options Options, selection Selection, prompt linkedPrompt) (string, error) { variation := syncdomain.Variation{ Mode: selection.Config.Mode, @@ -304,22 +253,24 @@ func createLinkedPrompt(options Options, selection Selection, prompt linkedPromp ModelConfigVersion: selection.ModelConfig.Version, Model: selection.ModelConfig.VariationModel(), } - if _, err := syncreference.ApplyToVariation(prompt.reference.Format, prompt.content, &variation); err != nil { + if err := syncreference.ApplyToVariation(prompt.reference.Format, prompt.content, &variation); err != nil { return "", err } + + // Every check must pass before the linked file changes. if variation.Mode != selection.Config.Mode { return "", fmt.Errorf("referenced prompt mode %q does not match config mode %q", variation.Mode, selection.Config.Mode) } if err := validateDerivedKey(variation.Key); err != nil { return "", err } - if variation.Name == "" { - return "", fmt.Errorf("variation name is required") + if err := variation.Validate(); err != nil { + return "", fmt.Errorf("variation %q: %w", variation.Key, err) } - for _, existingVariation := range selection.Config.Variations { - if existingVariation.Key == variation.Key { - return "", fmt.Errorf("variation %q already exists in config %q", variation.Key, selection.Config.Key) - } + if slices.ContainsFunc(selection.Config.Variations, func(existing syncdomain.Variation) bool { + return existing.Key == variation.Key + }) { + return "", fmt.Errorf("variation %q already exists in config %q", variation.Key, selection.Config.Key) } exists, err := options.Store.VariationExists(selection.Project.Key, selection.Config.Key, variation.Key) if err != nil { @@ -329,24 +280,22 @@ func createLinkedPrompt(options Options, selection Selection, prompt linkedPromp return "", fmt.Errorf("variation %q is already linked locally", variation.Key) } - // Validation above must complete before an empty referenced file is filled - // with interactively entered content. If wrapper creation then fails, put - // the source back exactly as the user had it. - target := filepath.Join(options.RepositoryRoot, filepath.FromSlash(prompt.reference.File)) + source := filepath.Join(options.RepositoryRoot, filepath.FromSlash(prompt.reference.File)) sourceChanged := !bytes.Equal(prompt.originalContent, prompt.content) var sourceMode os.FileMode if sourceChanged { - info, err := os.Stat(target) + info, err := os.Stat(source) if err != nil { return "", err } sourceMode = info.Mode().Perm() - if err := synclocal.ReplaceFileAtomically(target, prompt.reference.File, prompt.originalContent, prompt.content, sourceMode); err != nil { + err = synclocal.ReplaceFileAtomically(source, prompt.reference.File, prompt.originalContent, prompt.content, sourceMode) + if err != nil { return "", fmt.Errorf("write linked file %q: %w", prompt.reference.File, err) } } - paths, err := options.Store.Add([]synclocal.VariationFile{{ + creation, err := options.Store.Add([]synclocal.VariationFile{{ ProjectKey: selection.Project.Key, ConfigKey: selection.Config.Key, Upsert: true, @@ -355,22 +304,19 @@ func createLinkedPrompt(options Options, selection Selection, prompt linkedPromp }}) if err != nil { if sourceChanged { - err = errors.Join(err, restoreLinkedFile(target, prompt.reference.File, prompt.content, prompt.originalContent, sourceMode)) + restoreErr := synclocal.ReplaceFileAtomically( + source, prompt.reference.File, prompt.content, prompt.originalContent, sourceMode, + ) + if restoreErr != nil { + err = errors.Join(err, fmt.Errorf("restore linked file %q: %w", prompt.reference.File, restoreErr)) + } } return "", err } - return paths[0], nil + return creation.VariationPaths[0], nil } -// restoreLinkedFile rolls back a referenced file changed during a failed link. -func restoreLinkedFile(target, displayPath string, currentContent, originalContent []byte, mode os.FileMode) error { - if err := synclocal.ReplaceFileAtomically(target, displayPath, currentContent, originalContent, mode); err != nil { - return fmt.Errorf("restore linked file %q: %w", displayPath, err) - } - return nil -} - -// readLinkedPrompt resolves, reads, and parses a repository-contained source file. +// readLinkedPrompt reads and parses the external file. func readLinkedPrompt(options Options) (linkedPrompt, error) { reference, err := synclocal.NewReference(options.RepositoryRoot, options.WorkingDirectory, options.File, options.Format) if err != nil { @@ -387,19 +333,18 @@ func readLinkedPrompt(options Options) (linkedPrompt, error) { return linkedPrompt{reference: reference, parsed: prompt, originalContent: content, content: content}, nil } -// validateDerivedKey ensures a filename-derived key is also a safe path segment. +// validateDerivedKey makes sure that a key from a file name is a safe key. func validateDerivedKey(key string) error { - switch { - case key == "": - return fmt.Errorf("cannot derive a variation key from the linked filename") - case key == "." || key == ".." || strings.ContainsAny(key, `/\`) || strings.IndexByte(key, 0) >= 0: + if key == "" { + return errors.New("cannot derive a variation key from the linked filename") + } + if syncdomain.ValidateKey(key) != nil { return fmt.Errorf("linked filename produces invalid variation key %q", key) - default: - return nil } + return nil } -// requiredValue builds a reusable non-blank form validator. +// requiredValue returns a form validator that rejects a blank value. func requiredValue(label string) func(string) error { return func(value string) error { if strings.TrimSpace(value) == "" { @@ -409,37 +354,11 @@ func requiredValue(label string) func(string) error { } } -// displayName turns a kebab- or snake-case key into a readable default name. +// displayName converts a key such as "support-agent" to "Support agent". func displayName(key string) string { - name := strings.NewReplacer("-", " ", "_", " ").Replace(key) - runes := []rune(name) + runes := []rune(strings.NewReplacer("-", " ", "_", " ").Replace(key)) if len(runes) != 0 { runes[0] = unicode.ToUpper(runes[0]) } return string(runes) } - -// projectChoice keeps the readable project name above its stable key. -func projectChoice(project syncapi.Project) syncinteractive.Choice[syncapi.Project] { - return syncinteractive.Choice[syncapi.Project]{ - Title: project.Name, Description: "Key: " + project.Key, Value: project, - } -} - -// configChoice includes both the stable config identity and its mode. -func configChoice(config syncapi.Config) syncinteractive.Choice[syncapi.Config] { - return syncinteractive.Choice[syncapi.Config]{ - Title: config.Name, Description: fmt.Sprintf("Key: %s · Mode: %s", config.Key, config.Mode), Value: config, - } -} - -// modelConfigChoices adapts model configs to interactive labels. -func modelConfigChoices(configs []syncapi.ModelConfig) []syncinteractive.Choice[syncapi.ModelConfig] { - choices := make([]syncinteractive.Choice[syncapi.ModelConfig], 0, len(configs)) - for _, config := range configs { - choices = append(choices, syncinteractive.Choice[syncapi.ModelConfig]{ - Title: config.Name, Description: "Key: " + config.Key, Value: config, - }) - } - return choices -} diff --git a/internal/sync/link/link_test.go b/internal/sync/link/link_test.go index ea622d69..675703e6 100644 --- a/internal/sync/link/link_test.go +++ b/internal/sync/link/link_test.go @@ -77,12 +77,12 @@ func TestRunRejectsBlankExplicitMetadata(t *testing.T) { } } -func TestCreateWritesLinkedVariationWrapper(t *testing.T) { +func TestCreateLinkedPromptWritesLinkedVariationWrapper(t *testing.T) { root := t.TempDir() require.NoError(t, os.Mkdir(filepath.Join(root, "prompts"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(root, "prompts", "support-agent.md"), []byte("Be helpful.\n"), 0o644)) - path, err := Create(Options{ + path, err := create(Options{ Store: synclocal.NewStore(root), RepositoryRoot: root, WorkingDirectory: root, @@ -126,15 +126,15 @@ model: resources, err := synclocal.CompileWorkspace(root) require.NoError(t, err) require.Len(t, resources, 1) - require.Contains(t, string(resources[0].Payload), `"instructions":"Be helpful."`) - require.Contains(t, string(resources[0].Payload), `"modelName":"claude-3-5-sonnet-20241022"`) + require.Equal(t, "Be helpful.", resources[0].Variation.Instructions) + require.Equal(t, "claude-3-5-sonnet-20241022", resources[0].Variation.Model["modelName"]) } -func TestCreateRejectsExistingServerVariation(t *testing.T) { +func TestCreateLinkedPromptRejectsExistingServerVariation(t *testing.T) { root := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Help"), 0o644)) - _, err := Create(Options{ + _, err := create(Options{ Store: synclocal.NewStore(root), RepositoryRoot: root, WorkingDirectory: root, @@ -154,7 +154,7 @@ func TestCreateRejectsExistingServerVariation(t *testing.T) { require.ErrorContains(t, err, `variation "prompt" already exists`) } -func TestCreateValidatesDestinationBeforeUpdatingLinkedFile(t *testing.T) { +func TestCreateLinkedPromptValidatesDestinationBeforeUpdatingLinkedFile(t *testing.T) { root := t.TempDir() sourcePath := filepath.Join(root, "prompt.md") require.NoError(t, os.WriteFile(sourcePath, nil, 0o644)) @@ -243,3 +243,11 @@ func (*fakeCatalog) SearchConfigs(string, string, []syncdomain.VariationMode, in func (catalog *fakeCatalog) ModelConfigs(string) ([]syncapi.ModelConfig, error) { return []syncapi.ModelConfig{catalog.model}, nil } + +func create(options Options, selection Selection) (string, error) { + prompt, err := readLinkedPrompt(options) + if err != nil { + return "", err + } + return createLinkedPrompt(options, selection, prompt) +} diff --git a/internal/sync/local/attachment.go b/internal/sync/local/attachment.go index d4c4a878..c88a22df 100644 --- a/internal/sync/local/attachment.go +++ b/internal/sync/local/attachment.go @@ -2,11 +2,13 @@ package local import ( "bytes" + "cmp" "encoding/json" "errors" "fmt" "io" "io/fs" + "maps" "os" "path" "path/filepath" @@ -14,15 +16,11 @@ import ( "strings" syncdomain "github.com/launchdarkly/ldcli/internal/sync" - "gopkg.in/yaml.v3" ) -const ( - toolsDir = "tools" - skillsDir = "skills" - toolFileSuffix = ".json" - skillFileSuffix = ".md" -) +// A tool file is JSON. A skill file is Markdown with YAML front matter that +// has the key and the description. +const toolFormatVersion = 1 type toolFile struct { FormatVersion int `json:"formatVersion"` @@ -35,8 +33,7 @@ type skillFrontMatter struct { Description *string `yaml:"description"` } -// OrphanedAttachment identifies a managed dependency file that no local -// variation references. +// OrphanedAttachment is a tool or skill file that no variation uses. type OrphanedAttachment struct { ProjectKey string Kind syncdomain.AttachmentKind @@ -44,23 +41,22 @@ type OrphanedAttachment struct { Path string } -type attachmentFileID struct { - projectKey string - kind syncdomain.AttachmentKind - key string +// readAttachment reads the file of one tool or skill. +func readAttachment(fsys fs.FS, projectKey string, kind syncdomain.AttachmentKind, key string) (syncdomain.Attachment, error) { + switch kind { + case syncdomain.AttachmentTool: + return readTool(fsys, projectKey, key) + case syncdomain.AttachmentSkill: + return readSkill(fsys, projectKey, key) + default: + return syncdomain.Attachment{}, fmt.Errorf("unsupported attachment kind %q", kind) + } } -// readTool loads and validates the deterministic local file for one tool key. func readTool(fsys fs.FS, projectKey, key string) (syncdomain.Attachment, error) { - if err := validatePathSegment(key); err != nil { - return syncdomain.Attachment{}, fmt.Errorf("invalid tool key %q: %w", key, err) - } - - relativePath, _ := attachmentPath(projectKey, syncdomain.AttachmentTool, key) - filePath := path.Join(syncdomain.RootDir, relativePath) - data, err := readAttachmentFile(fsys, filePath) + data, err := readAttachmentFile(fsys, projectKey, syncdomain.AttachmentTool, key) if err != nil { - return syncdomain.Attachment{}, fmt.Errorf("read tool %q: %w", key, err) + return syncdomain.Attachment{}, err } var file toolFile @@ -69,38 +65,30 @@ func readTool(fsys fs.FS, projectKey, key string) (syncdomain.Attachment, error) if err := decoder.Decode(&file); err != nil { return syncdomain.Attachment{}, fmt.Errorf("parse tool %q: %w", key, err) } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { + if err := decoder.Decode(new(any)); err != io.EOF { if err == nil { - return syncdomain.Attachment{}, fmt.Errorf("parse tool %q: multiple JSON values are not supported", key) + err = errors.New("multiple JSON values are not supported") } return syncdomain.Attachment{}, fmt.Errorf("parse tool %q: %w", key, err) } + switch { - case file.FormatVersion != 1: + case file.FormatVersion != toolFormatVersion: return syncdomain.Attachment{}, fmt.Errorf("tool %q has unsupported formatVersion %d", key, file.FormatVersion) case file.Key != key: return syncdomain.Attachment{}, fmt.Errorf("tool key %q does not match filename %q", file.Key, key) case file.Schema == nil: return syncdomain.Attachment{}, fmt.Errorf("tool %q schema is required", key) } - return syncdomain.Attachment{ - Kind: syncdomain.AttachmentTool, Upsert: file.Upsert, Tool: &file.Tool, - }, nil + return syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Upsert: file.Upsert, Tool: &file.Tool}, nil } -// readSkill loads one Markdown skill and its editable description. The filename -// remains authoritative for the read-only key shown in front matter. +// readSkill reads one skill file. The file name is the key, and the key in +// the front matter must match it. func readSkill(fsys fs.FS, projectKey, key string) (syncdomain.Attachment, error) { - if err := validatePathSegment(key); err != nil { - return syncdomain.Attachment{}, fmt.Errorf("invalid skill key %q: %w", key, err) - } - - relativePath, _ := attachmentPath(projectKey, syncdomain.AttachmentSkill, key) - filePath := path.Join(syncdomain.RootDir, relativePath) - data, err := readAttachmentFile(fsys, filePath) + data, err := readAttachmentFile(fsys, projectKey, syncdomain.AttachmentSkill, key) if err != nil { - return syncdomain.Attachment{}, fmt.Errorf("read skill %q: %w", key, err) + return syncdomain.Attachment{}, err } var metadata skillFrontMatter @@ -108,7 +96,13 @@ func readSkill(fsys fs.FS, projectKey, key string) (syncdomain.Attachment, error if err != nil { return syncdomain.Attachment{}, fmt.Errorf("parse skill %q: %w", key, err) } - body = trimSkillBodySeparator(body) + // Rendering puts one blank line after the front matter. Remove it. + if bytes.HasPrefix(body, []byte("\r\n")) { + body = body[2:] + } else { + body = bytes.TrimPrefix(body, []byte("\n")) + } + switch { case metadata.Key != key: return syncdomain.Attachment{}, fmt.Errorf("skill key %q does not match filename %q", metadata.Key, key) @@ -117,108 +111,79 @@ func readSkill(fsys fs.FS, projectKey, key string) (syncdomain.Attachment, error case strings.TrimSpace(string(body)) == "": return syncdomain.Attachment{}, fmt.Errorf("skill %q markdown is required", key) } - skill := syncdomain.Skill{Key: key, Description: *metadata.Description, Markdown: string(body)} return syncdomain.Attachment{Kind: syncdomain.AttachmentSkill, Skill: &skill}, nil } -func readAttachment(fsys fs.FS, projectKey string, kind syncdomain.AttachmentKind, key string) (syncdomain.Attachment, error) { - switch kind { - case syncdomain.AttachmentTool: - return readTool(fsys, projectKey, key) - case syncdomain.AttachmentSkill: - return readSkill(fsys, projectKey, key) - default: - return syncdomain.Attachment{}, fmt.Errorf("unsupported attachment kind %q", kind) - } -} - -func trimSkillBodySeparator(body []byte) []byte { - if bytes.HasPrefix(body, []byte("\r\n")) { - return body[2:] +// readAttachmentFile reads one tool or skill file. It rejects a symbolic link +// or another special file, so that a read stays in the managed directory. +func readAttachmentFile(fsys fs.FS, projectKey string, kind syncdomain.AttachmentKind, key string) ([]byte, error) { + relativePath, err := attachmentPath(projectKey, kind, key) + if err != nil { + return nil, err } - return bytes.TrimPrefix(body, []byte("\n")) -} + filePath := path.Join(syncdomain.RootDir, relativePath) -// readAttachmentFile rejects links and non-regular files before reading -// managed dependency content. This keeps all reads inside the workspace tree. -func readAttachmentFile(fsys fs.FS, filePath string) ([]byte, error) { info, err := fs.Stat(fsys, filePath) - if err != nil { - return nil, err + switch { + case err != nil: + case info.Mode()&fs.ModeSymlink != 0: + err = errors.New("symbolic links are not supported") + case !info.Mode().IsRegular(): + err = errors.New("not a regular file") } - if info.Mode()&fs.ModeSymlink != 0 { - return nil, fmt.Errorf("symbolic links are not supported") + if err != nil { + return nil, fmt.Errorf("read %s %q: %w", kind, key, err) } - if !info.Mode().IsRegular() { - return nil, fmt.Errorf("not a regular file") + + data, err := fs.ReadFile(fsys, filePath) + if err != nil { + return nil, fmt.Errorf("read %s %q: %w", kind, key, err) } - return fs.ReadFile(fsys, filePath) + return data, nil } -// renderAttachmentFiles renders each shared dependency once, rejecting two -// variations that claim different local content for the same key. -func renderAttachmentFiles(resources []VariationFile) ([]RenderedVariationFile, error) { - files := make(map[string][]byte) - - for _, resource := range resources { - if err := validatePathSegment(resource.ProjectKey); err != nil { - return nil, fmt.Errorf("invalid project key %q: %w", resource.ProjectKey, err) - } - for _, attachment := range resource.Variation.Attachments { - var content []byte - var err error - - switch attachment.Kind { - case syncdomain.AttachmentTool: - content, err = renderTool(attachment) - case syncdomain.AttachmentSkill: - if attachment.Skill == nil { - return nil, fmt.Errorf("skill content is required") - } - content, err = renderSkill(*attachment.Skill) - default: - err = fmt.Errorf("unsupported attachment kind %q", attachment.Kind) - } +// renderAttachmentFiles renders the file of each tool and skill that the +// variations use, in path order. Two variations can share an attachment, but +// they must have the same content for it. +func renderAttachmentFiles(files []VariationFile) ([]RenderedFile, error) { + contents := make(map[string][]byte) + for _, file := range files { + for _, attachment := range file.Variation.Attachments { + filePath, err := attachmentPath(file.ProjectKey, attachment.Kind, attachment.Key()) if err != nil { return nil, err } - filePath, err := attachmentPath(resource.ProjectKey, attachment.Kind, attachment.Key()) + content, err := renderAttachment(attachment) if err != nil { return nil, err } - if err := addAttachmentFile(files, filePath, content); err != nil { - return nil, err + if existing, ok := contents[filePath]; ok && !bytes.Equal(existing, content) { + return nil, fmt.Errorf("attachment %q has conflicting local definitions", filePath) } + contents[filePath] = content } } - paths := make([]string, 0, len(files)) - for filePath := range files { - paths = append(paths, filePath) - } - slices.Sort(paths) - - rendered := make([]RenderedVariationFile, 0, len(paths)) - for _, filePath := range paths { - rendered = append(rendered, RenderedVariationFile{Path: filePath, Content: files[filePath]}) + rendered := make([]RenderedFile, 0, len(contents)) + for _, filePath := range slices.Sorted(maps.Keys(contents)) { + rendered = append(rendered, RenderedFile{Path: filePath, Content: contents[filePath]}) } return rendered, nil } -func renderTool(attachment syncdomain.Attachment) ([]byte, error) { - tool := attachment.Tool - if tool == nil { - return nil, fmt.Errorf("tool content is required") - } - if err := validatePathSegment(tool.Key); err != nil { - return nil, fmt.Errorf("invalid tool key %q: %w", tool.Key, err) - } - if tool.Schema == nil { - return nil, fmt.Errorf("tool %q schema is required", tool.Key) +func renderAttachment(attachment syncdomain.Attachment) ([]byte, error) { + switch { + case attachment.Kind == syncdomain.AttachmentTool && attachment.Tool != nil: + if attachment.Tool.Schema == nil { + return nil, fmt.Errorf("tool %q schema is required", attachment.Tool.Key) + } + return renderToolFile(toolFile{FormatVersion: toolFormatVersion, Upsert: attachment.Upsert, Tool: *attachment.Tool}) + case attachment.Kind == syncdomain.AttachmentSkill && attachment.Skill != nil: + return renderSkill(*attachment.Skill) + default: + return nil, fmt.Errorf("%s %q has no content", attachment.Kind, attachment.Key()) } - - return renderToolFile(toolFile{FormatVersion: 1, Upsert: attachment.Upsert, Tool: *tool}) } func renderToolFile(file toolFile) ([]byte, error) { @@ -233,36 +198,24 @@ func renderToolFile(file toolFile) ([]byte, error) { } func renderSkill(skill syncdomain.Skill) ([]byte, error) { - if err := validatePathSegment(skill.Key); err != nil { - return nil, fmt.Errorf("invalid skill key %q: %w", skill.Key, err) - } if strings.TrimSpace(skill.Markdown) == "" { return nil, fmt.Errorf("skill %q markdown is required", skill.Key) } - - var metadata bytes.Buffer - encoder := yaml.NewEncoder(&metadata) - encoder.SetIndent(2) - if err := encoder.Encode(skillFrontMatter{Key: skill.Key, Description: &skill.Description}); err != nil { + metadata, err := marshalYAML(skillFrontMatter{Key: skill.Key, Description: &skill.Description}) + if err != nil { return nil, fmt.Errorf("marshal skill %q metadata: %w", skill.Key, err) } - var file bytes.Buffer - file.WriteString("---\n") - file.Write(metadata.Bytes()) - file.WriteString("---\n\n") - file.WriteString(skill.Markdown) - return file.Bytes(), nil -} - -func addAttachmentFile(files map[string][]byte, filePath string, content []byte) error { - if existing, ok := files[filePath]; ok && !bytes.Equal(existing, content) { - return fmt.Errorf("attachment %q has conflicting local definitions", filePath) - } - files[filePath] = content - return nil + var content bytes.Buffer + content.WriteString("---\n") + content.Write(metadata) + content.WriteString("---\n\n") + content.WriteString(skill.Markdown) + return content.Bytes(), nil } +// preserveToolUpsert keeps the upsert flag of an existing tool file. The flag +// is a local choice, so new content from LaunchDarkly does not remove it. func preserveToolUpsert(filePath string, original, replacement []byte) ([]byte, error) { if !strings.HasSuffix(filePath, toolFileSuffix) { return replacement, nil @@ -281,63 +234,40 @@ func preserveToolUpsert(filePath string, original, replacement []byte) ([]byte, return renderToolFile(updated) } -// AttachVariation commits dependency files and their consuming variation in -// the same staged transaction. -func (store Store) AttachVariation(projectKey, configKey string, variation syncdomain.Variation) error { - _, err := store.ReplaceVariations([]VariationReplacement{{ - ProjectKey: projectKey, - ConfigKey: configKey, - Variation: variation, - }}) - return err -} - -// OrphanedAttachments returns managed tool and skill files that are no longer -// referenced by any local variation. +// OrphanedAttachments returns each tool and skill file that no local +// variation uses. func (store Store) OrphanedAttachments() ([]OrphanedAttachment, error) { - resources, err := CompileWorkspace(store.repositoryRoot) - if errors.Is(err, ErrNoDirectory) { - resources = nil - err = nil - } - if err != nil { + variations, err := CompileWorkspace(store.repositoryRoot) + if err != nil && !errors.Is(err, ErrNoDirectory) { return nil, err } - - referenced := make(map[attachmentFileID]struct{}) - for _, resource := range resources { - for _, attachment := range resource.Attachments { - referenced[attachmentFileID{ - projectKey: resource.ProjectKey, - kind: attachment.Kind, - key: attachment.Key(), - }] = struct{}{} + used := make(map[syncdomain.ResourceID]struct{}) + for _, variation := range variations { + for _, attachment := range variation.Variation.Attachments { + used[attachment.ID(variation.ProjectKey)] = struct{}{} } } var orphaned []OrphanedAttachment - err = filepath.WalkDir(store.root, func(filePath string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() { - return nil + err = filepath.WalkDir(store.root, func(filePath string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err } - relativePath, err := filepath.Rel(store.root, filePath) + relativePath, err := filepath.Rel(store.repositoryRoot, filePath) if err != nil { return err } - attachment, ok := attachmentFromPath(filepath.ToSlash(relativePath)) - if !ok { + id, ok := ParseManagedPath(filepath.ToSlash(relativePath)) + if !ok || id.Kind == syncdomain.KindVariation { return nil } - id := attachmentFileID{ - projectKey: attachment.ProjectKey, - kind: attachment.Kind, - key: attachment.Key, - } - if _, exists := referenced[id]; !exists { - orphaned = append(orphaned, attachment) + if _, isUsed := used[id]; !isUsed { + orphaned = append(orphaned, OrphanedAttachment{ + ProjectKey: id.ProjectKey, + Kind: syncdomain.AttachmentKind(id.Kind), + Key: id.LookupKey, + Path: strings.TrimPrefix(filepath.ToSlash(relativePath), syncdomain.RootDir+"/"), + }) } return nil }) @@ -347,22 +277,20 @@ func (store Store) OrphanedAttachments() ([]OrphanedAttachment, error) { if err != nil { return nil, fmt.Errorf("find unreferenced attachments: %w", err) } + slices.SortFunc(orphaned, func(left, right OrphanedAttachment) int { - if result := strings.Compare(left.ProjectKey, right.ProjectKey); result != 0 { - return result - } - if result := strings.Compare(string(left.Kind), string(right.Kind)); result != 0 { - return result - } - return strings.Compare(left.Key, right.Key) + return cmp.Or( + strings.Compare(left.ProjectKey, right.ProjectKey), + strings.Compare(string(left.Kind), string(right.Kind)), + strings.Compare(left.Key, right.Key), + ) }) return orphaned, nil } -// DeleteAttachments transactionally removes confirmed local attachment files. +// DeleteAttachments deletes tool and skill files in one transaction. func (store Store) DeleteAttachments(attachments []OrphanedAttachment) ([]string, error) { - deletions := make([]stagedDeletion, 0, len(attachments)) - seen := make(map[string]struct{}, len(attachments)) + paths := make([]string, 0, len(attachments)) for _, attachment := range attachments { relativePath, err := attachmentPath(attachment.ProjectKey, attachment.Kind, attachment.Key) if err != nil { @@ -371,75 +299,7 @@ func (store Store) DeleteAttachments(attachments []OrphanedAttachment) ([]string if relativePath != attachment.Path { return nil, fmt.Errorf("attachment path %q does not match %q", attachment.Path, relativePath) } - if _, duplicate := seen[relativePath]; duplicate { - return nil, fmt.Errorf("attachment %q was selected more than once", relativePath) - } - seen[relativePath] = struct{}{} - - absolutePath := filepath.Join(store.root, filepath.FromSlash(relativePath)) - if err := rejectSymlinkedPath(store.root, absolutePath); err != nil { - return nil, err - } - info, err := os.Lstat(absolutePath) - if err != nil { - return nil, fmt.Errorf("inspect attachment %s: %w", relativePath, err) - } - if !info.Mode().IsRegular() { - return nil, fmt.Errorf("attachment %s is not a regular file", relativePath) - } - deletions = append(deletions, stagedDeletion{ - relativePath: relativePath, - originalPath: absolutePath, - }) - } - - if err := stageDeletions(deletions); err != nil { - return nil, err - } - commitDeletions(store.root, deletions) - return deletionPaths(deletions), nil -} - -func attachmentFromPath(filePath string) (OrphanedAttachment, bool) { - parts := strings.Split(filePath, "/") - if len(parts) == 3 && parts[1] == toolsDir && strings.HasSuffix(parts[2], toolFileSuffix) { - key := strings.TrimSuffix(parts[2], toolFileSuffix) - expected, err := attachmentPath(parts[0], syncdomain.AttachmentTool, key) - return OrphanedAttachment{ - ProjectKey: parts[0], Kind: syncdomain.AttachmentTool, Key: key, Path: filePath, - }, err == nil && expected == filePath - } - if len(parts) == 3 && parts[1] == skillsDir && strings.HasSuffix(parts[2], skillFileSuffix) { - key := strings.TrimSuffix(parts[2], skillFileSuffix) - expected, err := attachmentPath(parts[0], syncdomain.AttachmentSkill, key) - return OrphanedAttachment{ - ProjectKey: parts[0], Kind: syncdomain.AttachmentSkill, Key: key, Path: filePath, - }, err == nil && expected == filePath - } - return OrphanedAttachment{}, false -} - -func attachmentPath(projectKey string, kind syncdomain.AttachmentKind, key string) (string, error) { - if err := validatePathSegment(projectKey); err != nil { - return "", fmt.Errorf("invalid project key %q: %w", projectKey, err) - } - if err := validatePathSegment(key); err != nil { - return "", fmt.Errorf("invalid %s key %q: %w", kind, key, err) - } - dir, suffix, err := attachmentLayout(kind) - if err != nil { - return "", err - } - return path.Join(projectKey, dir, key+suffix), nil -} - -func attachmentLayout(kind syncdomain.AttachmentKind) (string, string, error) { - switch kind { - case syncdomain.AttachmentTool: - return toolsDir, toolFileSuffix, nil - case syncdomain.AttachmentSkill: - return skillsDir, skillFileSuffix, nil - default: - return "", "", fmt.Errorf("unsupported attachment kind %q", kind) + paths = append(paths, relativePath) } + return store.deleteFiles(paths) } diff --git a/internal/sync/local/attachment_test.go b/internal/sync/local/attachment_test.go index 4977dbbc..3d245f3c 100644 --- a/internal/sync/local/attachment_test.go +++ b/internal/sync/local/attachment_test.go @@ -48,15 +48,18 @@ Help the customer. require.NoError(t, err) require.Len(t, resources, 1) - var variation syncdomain.Variation - require.NoError(t, json.Unmarshal(resources[0].Payload, &variation)) + variation := resources[0].Variation assert.Equal(t, []syncdomain.AttachmentRef{{Key: "search"}}, variation.Tools) assert.Equal(t, []syncdomain.AttachmentRef{{Key: "support"}}, variation.Skills) - require.Len(t, resources[0].Attachments, 2) - assert.Equal(t, "Search documentation", *resources[0].Attachments[0].Tool.Description) - assert.True(t, resources[0].Attachments[0].Upsert) - assert.Equal(t, "Follow the standard support process.", resources[0].Attachments[1].Skill.Description) - assert.Contains(t, resources[0].Attachments[1].Skill.Markdown, "Follow the support process.") + require.Len(t, variation.Attachments, 2) + tool, ok := variation.Attachment(syncdomain.AttachmentTool, "search") + require.True(t, ok) + assert.Equal(t, "Search documentation", *tool.Tool.Description) + assert.True(t, tool.Upsert) + skill, ok := variation.Attachment(syncdomain.AttachmentSkill, "support") + require.True(t, ok) + assert.Equal(t, "Follow the standard support process.", skill.Skill.Description) + assert.Contains(t, skill.Skill.Markdown, "Follow the support process.") } func TestRenderSkillIncludesReadOnlyKeyAndEditableDescription(t *testing.T) { @@ -150,7 +153,7 @@ func TestPreserveToolUpsertAcrossServerWrites(t *testing.T) { assert.Equal(t, "New", *file.Description) } -func TestAttachVariationRemovesNewAttachmentWhenVariationUpdateFails(t *testing.T) { +func TestReplaceVariationsRemovesNewAttachmentWhenVariationUpdateFails(t *testing.T) { root := t.TempDir() store := NewStore(root) existing := localVariation("default") @@ -164,7 +167,7 @@ func TestAttachVariationRemovesNewAttachmentWhenVariationUpdateFails(t *testing. Kind: syncdomain.AttachmentTool, Tool: &tool, }} - err = store.AttachVariation("project", "../invalid", variation) + _, err = store.ReplaceVariations([]VariationReplacement{{ProjectKey: "project", ConfigKey: "../invalid", Variation: variation}}) require.Error(t, err) _, statErr := os.Stat(filepath.Join(root, ".launchdarkly", "project", "tools", "search.json")) @@ -173,7 +176,7 @@ func TestAttachVariationRemovesNewAttachmentWhenVariationUpdateFails(t *testing. resources, compileErr := CompileWorkspace(root) require.NoError(t, compileErr) require.Len(t, resources, 1) - assert.Empty(t, resources[0].Attachments) + assert.Empty(t, resources[0].Variation.Attachments) } func TestReplaceVariationsLeavesAttachmentUnchangedWhenPreflightFails(t *testing.T) { @@ -323,6 +326,6 @@ Help the customer. Skills: []syncdomain.AttachmentRef{{Key: "support"}}, Attachments: []syncdomain.Attachment{{Kind: syncdomain.AttachmentSkill, Skill: &skill}}, } - err = NewStore(root).AttachVariation("project", "config", variation) + _, err = NewStore(root).ReplaceVariations([]VariationReplacement{{ProjectKey: "project", ConfigKey: "config", Variation: variation}}) require.ErrorContains(t, err, "symbolic links are not supported") } diff --git a/internal/sync/local/compile.go b/internal/sync/local/compile.go index c8d9ba1f..da285b96 100644 --- a/internal/sync/local/compile.go +++ b/internal/sync/local/compile.go @@ -1,7 +1,6 @@ package local import ( - "cmp" "errors" "fmt" "io/fs" @@ -14,34 +13,28 @@ import ( syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) -// ErrNoDirectory reports that no local sync directory exists. +// ErrNoDirectory reports that the repository has no managed directory. var ErrNoDirectory = errors.New(".launchdarkly directory not found") -// ParseError identifies the local file that could not be compiled. +// ParseError identifies the managed file that is not valid. type ParseError struct { Path string Err error } -// Error includes the repository-relative file that could not be compiled. -func (e ParseError) Error() string { - return e.Path + ": " + e.Err.Error() -} - -// Unwrap exposes the underlying syntax or validation error. -func (e ParseError) Unwrap() error { - return e.Err -} +func (e ParseError) Error() string { return e.Path + ": " + e.Err.Error() } +func (e ParseError) Unwrap() error { return e.Err } -// Compile reads local resources from an arbitrary filesystem. +// Compile reads every variation in a file system that contains a managed +// directory. Tests use it with an in-memory file system. func Compile(fsys fs.FS) ([]syncdomain.SyncedResource, error) { return compile(fsys, func(reference Reference) ([]byte, error) { return readReferenceFromFS(fsys, reference) }) } -// CompileWorkspace compiles local resources and safely resolves references -// within the Git repository. +// CompileWorkspace reads every variation in a repository. It rejects a +// symbolic link in a managed path, and a linked file outside the repository. func CompileWorkspace(repositoryRoot string) ([]syncdomain.SyncedResource, error) { root, err := filepath.EvalSymlinks(repositoryRoot) if err != nil { @@ -52,14 +45,13 @@ func CompileWorkspace(repositoryRoot string) ([]syncdomain.SyncedResource, error }) } -// Compile reads every resource managed by this store. +// Compile reads every variation in the repository of the store. func (store Store) Compile() ([]syncdomain.SyncedResource, error) { return CompileWorkspace(store.repositoryRoot) } -// workspaceFS rejects symlinks anywhere in a managed path. Managed files are -// owned by sync and must not redirect reads outside (or elsewhere within) the -// repository. +// workspaceFS opens repository files and rejects a path that goes through a +// symbolic link. Sync owns the managed files, so a link is not valid there. type workspaceFS struct { root string } @@ -68,7 +60,6 @@ func (fsys workspaceFS) Open(name string) (fs.File, error) { if !fs.ValidPath(name) { return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid} } - target := filepath.Join(fsys.root, filepath.FromSlash(name)) resolved, err := filepath.EvalSymlinks(target) if err != nil { @@ -80,8 +71,7 @@ func (fsys workspaceFS) Open(name string) (fs.File, error) { return os.Open(target) } -// compile walks every managed project and delegates reference loading to the -// caller so tests and real workspaces share the same parser. +// compile reads the variation files of each project, in identity order. func compile(fsys fs.FS, readReference func(Reference) ([]byte, error)) ([]syncdomain.SyncedResource, error) { entries, err := fs.ReadDir(fsys, syncdomain.RootDir) if errors.Is(err, fs.ErrNotExist) { @@ -91,55 +81,49 @@ func compile(fsys fs.FS, readReference func(Reference) ([]byte, error)) ([]syncd return nil, err } - var resources []syncdomain.SyncedResource - - // Directories immediately below .launchdarkly are project scopes. Files at - // the root, including the manifest, are handled by their owning packages. + var variations []syncdomain.SyncedResource for _, entry := range entries { + // Each directory is a project. Files in the managed directory itself + // are not resources. if !entry.IsDir() { continue } - - variations, err := compileProjectVariations(fsys, entry.Name(), readReference) + projectVariations, err := compileProject(fsys, entry.Name(), readReference) if err != nil { return nil, err } - - resources = append(resources, variations...) + variations = append(variations, projectVariations...) } - slices.SortFunc(resources, compareResources) - - return resources, nil + slices.SortFunc(variations, func(left, right syncdomain.SyncedResource) int { + return syncdomain.CompareResourceIDs(left.ID(), right.ID()) + }) + return variations, nil } -// compileProjectVariations turns every supported wrapper in one project into -// the common resource representation consumed by reconciliation. -func compileProjectVariations( +// compileProject reads every variation file in one project. +func compileProject( fsys fs.FS, projectKey string, readReference func(Reference) ([]byte, error), ) ([]syncdomain.SyncedResource, error) { - dir := path.Join(syncdomain.RootDir, projectKey, configsDir) - - var resources []syncdomain.SyncedResource + configsRoot := path.Join(syncdomain.RootDir, projectKey, configsDir) + readAttachment := func(kind syncdomain.AttachmentKind, key string) (syncdomain.Attachment, error) { + return readAttachment(fsys, projectKey, kind, key) + } - err := fs.WalkDir(fsys, dir, func(name string, entry fs.DirEntry, err error) error { - if err != nil { - // A project may legitimately contain no resources of this kind. - if name == dir && errors.Is(err, fs.ErrNotExist) { - return nil - } + var variations []syncdomain.SyncedResource + err := fs.WalkDir(fsys, configsRoot, func(name string, entry fs.DirEntry, err error) error { + switch { + case name == configsRoot && errors.Is(err, fs.ErrNotExist): + // A project can have no variations. + return nil + case err != nil: return err - } - if entry.IsDir() { + case entry.IsDir(): return nil } - - relPath := strings.TrimPrefix(name, dir+"/") - // Ignore files owned by other resource kinds. Each compiler recognizes - // only its own directory shape and suffix. - if relPath == name || !isVariationFile(relPath) { + if id, ok := ParseManagedPath(name); !ok || id.Kind != syncdomain.KindVariation { return nil } @@ -147,37 +131,21 @@ func compileProjectVariations( if err != nil { return err } - - resource, err := parseVariation(localFile{ - ProjectKey: projectKey, - RelPath: relPath, - Data: data, - ReadReference: readReference, - ReadAttachment: func(kind syncdomain.AttachmentKind, key string) (syncdomain.Attachment, error) { - return readAttachment(fsys, projectKey, kind, key) - }, + variation, err := parseVariation(localFile{ + ProjectKey: projectKey, + RelPath: strings.TrimPrefix(name, configsRoot+"/"), + Data: data, + ReadReference: readReference, + ReadAttachment: readAttachment, }) if err != nil { - // Preserve the managed path so users can locate malformed content - // while callers can still inspect the parser error through Unwrap. return ParseError{Path: name, Err: err} } - - resources = append(resources, resource) - + variations = append(variations, variation) return nil }) if err != nil { return nil, err } - - return resources, nil -} - -// compareResources provides deterministic project and lookup-key ordering. -func compareResources(a, b syncdomain.SyncedResource) int { - return cmp.Or( - cmp.Compare(a.ProjectKey, b.ProjectKey), - cmp.Compare(a.LookupKey, b.LookupKey), - ) + return variations, nil } diff --git a/internal/sync/local/compile_test.go b/internal/sync/local/compile_test.go index 93681be8..a1286fc7 100644 --- a/internal/sync/local/compile_test.go +++ b/internal/sync/local/compile_test.go @@ -1,7 +1,6 @@ package local import ( - "encoding/json" "errors" "io/fs" "testing" @@ -89,8 +88,7 @@ func TestCompile(t *testing.T) { assert.Equal(t, "my-config-key/my-first-variation", variation.LookupKey) assert.True(t, variation.Upsert) - var payload syncdomain.Variation - require.NoError(t, json.Unmarshal(variation.Payload, &payload)) + payload := variation.Variation assert.Equal(t, syncdomain.VariationModeCompletion, payload.Mode) assert.Equal(t, "my-first-variation", payload.Key) assert.Equal(t, "This is the prompt name", payload.Name) diff --git a/internal/sync/local/completion.go b/internal/sync/local/completion.go new file mode 100644 index 00000000..5ba6f9ae --- /dev/null +++ b/internal/sync/local/completion.go @@ -0,0 +1,101 @@ +package local + +import ( + "errors" + "fmt" + "strings" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +// parseCompletionMessages reads the role blocks of a completion body, for +// example "Hi". A body without role blocks is one system message. +func parseCompletionMessages(body string) ([]syncdomain.Message, error) { + body = syncdomain.NormalizePromptText(body) + if body == "" { + return nil, nil + } + if _, _, _, found := nextOpenTag(body, 0); !found { + return []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: body}}, nil + } + + var messages []syncdomain.Message + for cursor := 0; cursor < len(body); { + start, role, contentStart, found := nextOpenTag(body, cursor) + if !found { + start = len(body) + } + if strings.TrimSpace(body[cursor:start]) != "" { + return nil, errors.New("unexpected text outside message tags") + } + if !found { + break + } + + contentEnd, closeEnd, closed := matchingClose(body, contentStart, role) + if !closed { + return nil, fmt.Errorf("unclosed <%s> tag", role) + } + content := syncdomain.NormalizePromptText(body[contentStart:contentEnd]) + messages = append(messages, syncdomain.Message{Role: role, Content: unescapeMessageContent(content, role)}) + cursor = closeEnd + } + return messages, nil +} + +// escapeMessageContent escapes the role tags and backslashes in a message, +// so that the rendered body parses back to the same message. +func escapeMessageContent(content, role string) string { + content = strings.ReplaceAll(content, `\`, `\\`) + content = strings.ReplaceAll(content, "<"+role+">", `<\`+role+">") + return strings.ReplaceAll(content, "", `<\/`+role+">") +} + +// unescapeMessageContent reverses escapeMessageContent. +func unescapeMessageContent(content, role string) string { + content = strings.ReplaceAll(content, `<\/`+role+">", "") + content = strings.ReplaceAll(content, `<\`+role+">", "<"+role+">") + return strings.ReplaceAll(content, `\\`, `\`) +} + +// nextOpenTag finds the first role tag at or after from. +func nextOpenTag(body string, from int) (start int, role string, contentStart int, found bool) { + start = -1 + for _, candidate := range syncdomain.MessageRoles { + tag := "<" + candidate + ">" + index := strings.Index(body[from:], tag) + if index < 0 { + continue + } + if absolute := from + index; start < 0 || absolute < start { + start, role, contentStart, found = absolute, candidate, absolute+len(tag), true + } + } + return start, role, contentStart, found +} + +// matchingClose finds the closing tag that balances the role block that +// starts at from. A nested block with the same role increases the depth. +func matchingClose(body string, from int, role string) (contentEnd, closeEnd int, found bool) { + openTag, closeTag := "<"+role+">", "" + depth := 1 + for index := from; index < len(body); { + nextClose := strings.Index(body[index:], closeTag) + if nextClose < 0 { + return 0, 0, false + } + if nextOpen := strings.Index(body[index:], openTag); nextOpen >= 0 && nextOpen < nextClose { + depth++ + index += nextOpen + len(openTag) + continue + } + + closeAt := index + nextClose + depth-- + if depth == 0 { + return closeAt, closeAt + len(closeTag), true + } + index = closeAt + len(closeTag) + } + return 0, 0, false +} diff --git a/internal/sync/local/create.go b/internal/sync/local/create.go new file mode 100644 index 00000000..c6d78bfa --- /dev/null +++ b/internal/sync/local/create.go @@ -0,0 +1,192 @@ +package local + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Creation records the result of a create. VariationPaths are the variation +// files to report to the user. CreatedPaths are every file that the create +// wrote, so that RollbackCreation can remove them. +type Creation struct { + VariationPaths []string + CreatedPaths []string +} + +// Render renders the files for a batch of variations without writing them. +// Tool and skill files come first, then the variation files that use them. +func (store Store) Render(files []VariationFile) ([]RenderedFile, error) { + attachments, err := renderAttachmentFiles(files) + if err != nil { + return nil, err + } + + rendered := attachments + seen := make(map[string]struct{}, len(files)) + for _, file := range files { + relativePath, err := variationPath(file.ProjectKey, file.ConfigKey, file.Variation.Key) + if err != nil { + return nil, err + } + if _, duplicate := seen[relativePath]; duplicate { + return nil, fmt.Errorf("variation %q was selected more than once", file.Variation.Key) + } + seen[relativePath] = struct{}{} + + content, err := renderVariationFile(file) + if err != nil { + return nil, err + } + rendered = append(rendered, RenderedFile{Path: relativePath, Content: content}) + } + return rendered, nil +} + +// Bootstrap creates the managed directory with a first batch of variations. +// It builds the directory beside the final path and renames it into place, +// so that a failure leaves no partial directory. +func (store Store) Bootstrap(files []VariationFile) (Creation, error) { + if _, err := os.Stat(store.root); err == nil { + return Creation{}, fmt.Errorf("%s already exists", store.root) + } else if !errors.Is(err, os.ErrNotExist) { + return Creation{}, fmt.Errorf("inspect %s: %w", store.root, err) + } + + stagingDirectory, err := os.MkdirTemp(filepath.Dir(store.root), ".launchdarkly.tmp-") + if err != nil { + return Creation{}, fmt.Errorf("create bootstrap staging directory: %w", err) + } + defer func() { _ = os.RemoveAll(stagingDirectory) }() + + staged := Store{repositoryRoot: store.repositoryRoot, root: stagingDirectory} + creation, err := staged.Add(files) + if err != nil { + return Creation{}, err + } + if err := os.Rename(stagingDirectory, store.root); err != nil { + return Creation{}, fmt.Errorf("finish bootstrap: %w", err) + } + return creation, nil +} + +// Add creates a batch of variation files and the tool and skill files that +// they use. Add never overwrites a file. An existing variation file is an +// error, and an existing tool or skill file stays as it is, so that a local +// edit is not lost. If one file fails, Add removes the files that it created. +func (store Store) Add(files []VariationFile) (Creation, error) { + rendered, err := store.Render(files) + if err != nil { + return Creation{}, err + } + + var creation Creation + for _, file := range rendered { + created, err := createFileIfAbsent(store.root, store.absolute(file.Path), file.Content) + if err != nil { + return Creation{}, errors.Join(err, store.RollbackCreation(creation)) + } + if created { + creation.CreatedPaths = append(creation.CreatedPaths, file.Path) + } + if strings.HasSuffix(file.Path, variationFileSuffix) { + creation.VariationPaths = append(creation.VariationPaths, file.Path) + } + } + return creation, nil +} + +// RollbackCreation removes the files that one create wrote, in reverse order. +func (store Store) RollbackCreation(creation Creation) error { + var failures []error + for index := len(creation.CreatedPaths) - 1; index >= 0; index-- { + relativePath := creation.CreatedPaths[index] + if err := os.Remove(store.absolute(relativePath)); err != nil && !errors.Is(err, os.ErrNotExist) { + failures = append(failures, fmt.Errorf("remove created resource %s: %w", relativePath, err)) + } + } + if err := store.RemoveEmptyDirectories(); err != nil { + failures = append(failures, err) + } + return errors.Join(failures...) +} + +// createFileIfAbsent creates one file and reports whether it did. An existing +// variation file is an error. An existing tool or skill file is kept. +func createFileIfAbsent(root, path string, data []byte) (bool, error) { + _, err := os.Stat(path) + switch { + case err == nil: + if err := rejectSymlinkedPath(root, path); err != nil { + return false, err + } + if strings.HasSuffix(path, variationFileSuffix) { + return false, fmt.Errorf("%w: %s", ErrVariationExists, path) + } + return false, nil + case !errors.Is(err, os.ErrNotExist): + return false, fmt.Errorf("inspect resource file %s: %w", filepath.Base(path), err) + } + if err := createFile(root, path, data); err != nil { + return false, err + } + return true, nil +} + +// createFile writes data to a temporary file beside path, and then hard-links +// the temporary file to path. The link fails if path exists, so createFile +// never overwrites a file that another process created. +func createFile(root, path string, data []byte) error { + if err := rejectSymlinkedPath(root, path); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create resource directory: %w", err) + } + + tempPath, err := writeTempFile(path, data, 0o644) + if err != nil { + return err + } + defer func() { _ = os.Remove(tempPath) }() + + if err := os.Link(tempPath, path); err != nil { + if errors.Is(err, os.ErrExist) { + return fmt.Errorf("%w: %s", ErrVariationExists, path) + } + return fmt.Errorf("create resource file %s: %w", filepath.Base(path), err) + } + return nil +} + +// writeTempFile writes data to a new temporary file beside destination and +// flushes it to disk. The caller must rename or remove the temporary file. +func writeTempFile(destination string, data []byte, mode os.FileMode) (string, error) { + name := filepath.Base(destination) + temp, err := os.CreateTemp(filepath.Dir(destination), "."+name+".tmp-") + if err != nil { + return "", fmt.Errorf("stage file %s: %w", name, err) + } + fail := func(err error) (string, error) { + _ = temp.Close() + _ = os.Remove(temp.Name()) + return "", err + } + + if err := temp.Chmod(mode); err != nil { + return fail(fmt.Errorf("set permissions of staged file %s: %w", name, err)) + } + if _, err := temp.Write(data); err != nil { + return fail(fmt.Errorf("write staged file %s: %w", name, err)) + } + if err := temp.Sync(); err != nil { + return fail(fmt.Errorf("sync staged file %s: %w", name, err)) + } + if err := temp.Close(); err != nil { + _ = os.Remove(temp.Name()) + return "", fmt.Errorf("close staged file %s: %w", name, err) + } + return temp.Name(), nil +} diff --git a/internal/sync/local/delete.go b/internal/sync/local/delete.go index c03f2964..18750c2b 100644 --- a/internal/sync/local/delete.go +++ b/internal/sync/local/delete.go @@ -7,94 +7,107 @@ import ( "path/filepath" ) +// stagedDeletion is one file in a delete transaction. The file first moves to +// backupPath, so that a failure can move it back. type stagedDeletion struct { relativePath string - originalPath string + path string backupPath string } -// DeleteVariations preflights and stages a batch before removing any wrapper -// permanently, allowing staging failures to restore the original files. -func (store Store) DeleteVariations(resources []VariationDeletion) ([]string, error) { - deletions, err := store.prepareDeletions(resources) - if err != nil { - return nil, err - } - - // Move the complete batch to same-directory backups before removing - // anything permanently. A staging failure can therefore restore every file. - if err := stageDeletions(deletions); err != nil { - return nil, err +// DeleteVariations deletes a batch of variation files and returns their paths. +func (store Store) DeleteVariations(deletions []VariationDeletion) ([]string, error) { + paths := make([]string, 0, len(deletions)) + for _, deletion := range deletions { + relativePath, err := variationPath(deletion.ProjectKey, deletion.ConfigKey, deletion.VariationKey) + if err != nil { + return nil, err + } + paths = append(paths, relativePath) } - commitDeletions(store.root, deletions) - return deletionPaths(deletions), nil + return store.deleteFiles(paths) } -// prepareDeletions validates the complete batch and rejects duplicate paths -// before filesystem state changes. -func (store Store) prepareDeletions(resources []VariationDeletion) ([]stagedDeletion, error) { - deletions := make([]stagedDeletion, 0, len(resources)) - seenPaths := make(map[string]struct{}, len(resources)) +// deleteFiles deletes a batch of managed files in one transaction. It first +// moves every file to a backup beside it. If a move fails, it moves the +// files back. When every file has moved, it removes the backups. +func (store Store) deleteFiles(relativePaths []string) ([]string, error) { + deletions := make([]stagedDeletion, 0, len(relativePaths)) + seen := make(map[string]struct{}, len(relativePaths)) + for _, relativePath := range relativePaths { + if _, duplicate := seen[relativePath]; duplicate { + return nil, fmt.Errorf("%s was selected more than once", relativePath) + } + seen[relativePath] = struct{}{} - for _, resource := range resources { - absolutePath, err := store.variationPath(resource.ProjectKey, resource.ConfigKey, resource.VariationKey) - if err != nil { + path := store.absolute(relativePath) + if err := rejectSymlinkedPath(store.root, path); err != nil { return nil, err } - // Use Lstat so the regular-file check rejects symlink wrappers rather - // than following them to a file outside the managed workspace. - info, err := os.Lstat(absolutePath) + // Lstat does not follow a symbolic link, so a link is not a regular file. + info, err := os.Lstat(path) if err != nil { - return nil, fmt.Errorf("inspect variation %s: %w", resource.VariationKey, err) + return nil, fmt.Errorf("inspect %s: %w", relativePath, err) } if !info.Mode().IsRegular() { - return nil, fmt.Errorf("variation %s is not a regular file", resource.VariationKey) - } - if _, duplicate := seenPaths[absolutePath]; duplicate { - return nil, fmt.Errorf("variation %q was selected more than once", resource.VariationKey) + return nil, fmt.Errorf("%s is not a regular file", relativePath) } - seenPaths[absolutePath] = struct{}{} - relativePath, err := filepath.Rel(store.root, absolutePath) - if err != nil { - return nil, fmt.Errorf("resolve variation %s: %w", resource.VariationKey, err) + deletions = append(deletions, stagedDeletion{relativePath: relativePath, path: path}) + } + + if err := stageDeletions(deletions); err != nil { + return nil, err + } + // When every file is in its backup, the delete is complete. Removing a + // backup can fail, but restoring only some files is worse than a backup + // that remains. + for _, deletion := range deletions { + if err := os.Remove(deletion.backupPath); err == nil { + removeEmptyParents(store.root, filepath.Dir(deletion.path)) } - deletions = append(deletions, stagedDeletion{ - relativePath: filepath.ToSlash(relativePath), - originalPath: absolutePath, - }) } - return deletions, nil + return relativePaths, nil } -// stageDeletions renames wrappers to same-directory backups and restores prior -// renames if any later rename fails. +// stageDeletions moves each file to a backup. If a move fails, it moves the +// earlier files back. func stageDeletions(deletions []stagedDeletion) error { - var staged []stagedDeletion for index := range deletions { - backupPath, err := reserveBackupPath(deletions[index].originalPath) + backupPath, err := reserveBackupPath(deletions[index].path) if err == nil { - err = os.Rename(deletions[index].originalPath, backupPath) + err = os.Rename(deletions[index].path, backupPath) } if err != nil { - return errors.Join(fmt.Errorf("stage deletion %s: %w", deletions[index].relativePath, err), rollbackDeletions(staged)) + return errors.Join( + fmt.Errorf("stage deletion %s: %w", deletions[index].relativePath, err), + restoreDeletions(deletions[:index]), + ) } - deletions[index].backupPath = backupPath - staged = append(staged, deletions[index]) } return nil } -// reserveBackupPath obtains a collision-free backup name beside a wrapper. -func reserveBackupPath(originalPath string) (string, error) { - temp, err := os.CreateTemp(filepath.Dir(originalPath), "."+filepath.Base(originalPath)+".deleted-") +// restoreDeletions moves each backup to its original path, in reverse order. +func restoreDeletions(deletions []stagedDeletion) error { + var failures []error + for index := len(deletions) - 1; index >= 0; index-- { + if err := os.Rename(deletions[index].backupPath, deletions[index].path); err != nil { + failures = append(failures, fmt.Errorf("restore %s: %w", deletions[index].relativePath, err)) + } + } + return errors.Join(failures...) +} + +// reserveBackupPath returns an unused name beside path. It creates a +// temporary file to reserve the name, and then removes the file so that a +// rename can use the name. +func reserveBackupPath(path string) (string, error) { + temp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".deleted-") if err != nil { return "", err } - backupPath := temp.Name() - // CreateTemp reserves a collision-free name. Removing the placeholder lets - // Rename move the original into that exact same-directory location. if err := temp.Close(); err != nil { _ = os.Remove(backupPath) return "", err @@ -105,47 +118,13 @@ func reserveBackupPath(originalPath string) (string, error) { return backupPath, nil } -// commitDeletions treats the completed batch rename as the commit point. -// Backup cleanup is best effort because restoring only part of the batch would -// make the visible workspace inconsistent again. -func commitDeletions(root string, deletions []stagedDeletion) { - for _, deletion := range deletions { - if err := os.Remove(deletion.backupPath); err == nil { - removeEmptyParentsThroughRoot(root, filepath.Dir(deletion.originalPath)) - } - } -} - -// deletionPaths returns the stable repository-relative paths reported to callers. -func deletionPaths(deletions []stagedDeletion) []string { - paths := make([]string, len(deletions)) - for index, deletion := range deletions { - paths[index] = deletion.relativePath - } - return paths -} - -// rollbackDeletions restores staged wrappers in reverse order. -func rollbackDeletions(deletions []stagedDeletion) error { - var rollbackErr error - for index := len(deletions) - 1; index >= 0; index-- { - if err := os.Rename(deletions[index].backupPath, deletions[index].originalPath); err != nil { - rollbackErr = errors.Join(rollbackErr, fmt.Errorf("restore variation %s: %w", deletions[index].relativePath, err)) - } - } - return rollbackErr -} - -// removeEmptyParentsThroughRoot removes empty variation, config, and project -// directories. It also removes the .launchdarkly root when the store is empty. -func removeEmptyParentsThroughRoot(root, current string) { - for { - if err := os.Remove(current); err != nil { - return - } - if current == root { +// removeEmptyParents removes directory and each empty parent, up to and +// including root. +func removeEmptyParents(root, directory string) { + for isWithin(root, directory) { + if err := os.Remove(directory); err != nil || directory == root { return } - current = filepath.Dir(current) + directory = filepath.Dir(directory) } } diff --git a/internal/sync/local/layout.go b/internal/sync/local/layout.go new file mode 100644 index 00000000..8e26ce4d --- /dev/null +++ b/internal/sync/local/layout.go @@ -0,0 +1,104 @@ +package local + +import ( + "cmp" + "fmt" + "path" + "path/filepath" + "strings" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +// The managed directory has this layout: +// +// .launchdarkly//configs//.prompt.md +// .launchdarkly//tools/.json +// .launchdarkly//skills/.md +const ( + configsDir = "configs" + toolsDir = "tools" + skillsDir = "skills" + + variationFileSuffix = ".prompt.md" + toolFileSuffix = ".json" + skillFileSuffix = ".md" +) + +// ParseManagedPath returns the resource that a managed file stores. The path +// is relative to the repository root and uses forward slashes. ParseManagedPath +// returns false for every other path. +func ParseManagedPath(file string) (syncdomain.ResourceID, bool) { + parts := strings.Split(file, "/") + if len(parts) < 4 || parts[0] != syncdomain.RootDir { + return syncdomain.ResourceID{}, false + } + + var id syncdomain.ResourceID + var stem string + switch { + case len(parts) == 5 && parts[2] == configsDir && strings.HasSuffix(parts[4], variationFileSuffix): + stem = strings.TrimSuffix(parts[4], variationFileSuffix) + if syncdomain.ValidateKey(parts[3]) != nil { + return syncdomain.ResourceID{}, false + } + id = syncdomain.VariationID(parts[1], parts[3], stem) + case len(parts) == 4 && parts[2] == toolsDir && strings.HasSuffix(parts[3], toolFileSuffix): + stem = strings.TrimSuffix(parts[3], toolFileSuffix) + id = syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: parts[1], LookupKey: stem} + case len(parts) == 4 && parts[2] == skillsDir && strings.HasSuffix(parts[3], skillFileSuffix): + stem = strings.TrimSuffix(parts[3], skillFileSuffix) + id = syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: parts[1], LookupKey: stem} + default: + return syncdomain.ResourceID{}, false + } + + if syncdomain.ValidateKey(parts[1]) != nil || syncdomain.ValidateKey(stem) != nil { + return syncdomain.ResourceID{}, false + } + return id, true +} + +// variationPath returns the path of a variation file relative to the managed +// directory. It makes sure that each key is a safe path segment. +func variationPath(projectKey, configKey, variationKey string) (string, error) { + if err := cmp.Or( + validateKey("project", projectKey), + validateKey("config", configKey), + validateKey("variation", variationKey), + ); err != nil { + return "", err + } + return path.Join(projectKey, configsDir, configKey, variationKey+variationFileSuffix), nil +} + +// attachmentPath returns the path of a tool or skill file relative to the +// managed directory. It makes sure that each key is a safe path segment. +func attachmentPath(projectKey string, kind syncdomain.AttachmentKind, key string) (string, error) { + if err := cmp.Or(validateKey("project", projectKey), validateKey(string(kind), key)); err != nil { + return "", err + } + switch kind { + case syncdomain.AttachmentTool: + return path.Join(projectKey, toolsDir, key+toolFileSuffix), nil + case syncdomain.AttachmentSkill: + return path.Join(projectKey, skillsDir, key+skillFileSuffix), nil + default: + return "", fmt.Errorf("unsupported attachment kind %q", kind) + } +} + +// validateKey makes sure that key is a safe path segment. The name is the kind +// of key, for example "project", and appears in the error. +func validateKey(name, key string) error { + if err := syncdomain.ValidateKey(key); err != nil { + return fmt.Errorf("invalid %s key %q: %w", name, key, err) + } + return nil +} + +// isWithin reports whether target is root or a path below root. +func isWithin(root, target string) bool { + relative, err := filepath.Rel(root, target) + return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} diff --git a/internal/sync/local/reference.go b/internal/sync/local/reference.go index 2ab09ff0..4fbb3537 100644 --- a/internal/sync/local/reference.go +++ b/internal/sync/local/reference.go @@ -7,30 +7,29 @@ import ( "os" "path" "path/filepath" - "slices" "strings" syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) -// Reference identifies an external source file and the adapter format that -// converts it into a synchronized resource. +// Reference is the "ref" field of a linked variation. It names a file in the +// repository and the format of that file. type Reference struct { File string `yaml:"file"` Format string `yaml:"format"` } -// NewReference validates a user-supplied path and converts it to the -// repository-relative path stored in frontmatter. +// NewReference converts a path that the user entered to a reference. The +// path can be relative to workingDirectory. The file must be in the repository. func NewReference(repositoryRoot, workingDirectory, file, format string) (Reference, error) { if file == "" { - return Reference{}, fmt.Errorf("linked file path is required") + return Reference{}, errors.New("linked file path is required") } if !filepath.IsAbs(file) { file = filepath.Join(workingDirectory, file) } - // Compare resolved paths, not their textual spelling. Otherwise a path that - // appears to be inside the repository could escape through a symlink. + // Compare resolved paths. A path that looks like it is in the repository + // can go out of it through a symbolic link. target, err := filepath.EvalSymlinks(file) if err != nil { return Reference{}, fmt.Errorf("resolve linked file %q: %w", file, err) @@ -39,11 +38,11 @@ func NewReference(repositoryRoot, workingDirectory, file, format string) (Refere if err != nil { return Reference{}, fmt.Errorf("resolve repository root: %w", err) } - relative, err := filepath.Rel(root, target) - if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return Reference{}, fmt.Errorf("linked file must be inside the Git repository") + if !isWithin(root, target) { + return Reference{}, errors.New("linked file must be inside the Git repository") } + relative, _ := filepath.Rel(root, target) reference := Reference{File: filepath.ToSlash(relative), Format: format} if _, err := resolveReferencePath(root, reference); err != nil { return Reference{}, err @@ -51,40 +50,32 @@ func NewReference(repositoryRoot, workingDirectory, file, format string) (Refere return reference, nil } -// readReferenceFromFS reads a validated reference from an abstract filesystem. -func readReferenceFromFS(fsys fs.FS, reference Reference) ([]byte, error) { - if err := validateReference(reference); err != nil { - return nil, err - } - data, err := fs.ReadFile(fsys, reference.File) - if err != nil { - return nil, fmt.Errorf("read referenced file %q: %w", reference.File, err) - } - return data, nil -} - -// readWorkspaceReference resolves symlinks before reading a source from disk. -func readWorkspaceReference(repositoryRoot string, reference Reference) ([]byte, error) { - target, err := resolveReferencePath(repositoryRoot, reference) - if err != nil { - return nil, err - } - data, err := os.ReadFile(target) - if err != nil { - return nil, fmt.Errorf("read referenced file %q: %w", reference.File, err) +// validateReference makes sure that the file is a clean path in the +// repository and outside the managed directory. +func validateReference(reference Reference) error { + switch { + case reference.File == "": + return errors.New("ref.file is required") + case reference.Format == "": + return errors.New("ref.format is required") + case !fs.ValidPath(reference.File): + return fmt.Errorf("ref.file %q must be a repository-relative path", reference.File) + case reference.File == syncdomain.RootDir || strings.HasPrefix(reference.File, syncdomain.RootDir+"/"): + return fmt.Errorf("ref.file %q must be outside %s", reference.File, syncdomain.RootDir) + case path.Clean(reference.File) != reference.File: + return fmt.Errorf("ref.file %q must be a clean repository-relative path", reference.File) + default: + return nil } - return data, nil } -// resolveReferencePath proves that both the declared path and its resolved -// symlink target remain inside the repository. +// resolveReferencePath returns the absolute path of a linked file. It +// resolves symbolic links on each call, because a link can change after the +// variation was linked. func resolveReferencePath(repositoryRoot string, reference Reference) (string, error) { if err := validateReference(reference); err != nil { return "", err } - - // Re-resolve both sides on every read. A symlink may have changed since the - // wrapper was created, so validation at link time is not sufficient. root, err := filepath.EvalSymlinks(repositoryRoot) if err != nil { return "", fmt.Errorf("resolve repository root: %w", err) @@ -93,8 +84,7 @@ func resolveReferencePath(repositoryRoot string, reference Reference) (string, e if err != nil { return "", fmt.Errorf("resolve referenced file %q: %w", reference.File, err) } - relative, err := filepath.Rel(root, target) - if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + if !isWithin(root, target) { return "", fmt.Errorf("referenced file %q resolves outside the Git repository", reference.File) } info, err := os.Stat(target) @@ -107,79 +97,25 @@ func resolveReferencePath(repositoryRoot string, reference Reference) (string, e return target, nil } -// validateReference checks the portable, repository-relative reference syntax. -func validateReference(reference Reference) error { - switch { - case reference.File == "": - return fmt.Errorf("ref.file is required") - case reference.Format == "": - return fmt.Errorf("ref.format is required") - case !fs.ValidPath(reference.File): - return fmt.Errorf("ref.file %q must be a repository-relative path", reference.File) - case reference.File == syncdomain.RootDir || strings.HasPrefix(reference.File, syncdomain.RootDir+"/"): - return fmt.Errorf("ref.file %q must be outside %s", reference.File, syncdomain.RootDir) - case path.Clean(reference.File) != reference.File: - return fmt.Errorf("ref.file %q must be a clean repository-relative path", reference.File) - default: - return nil +func readReferenceFromFS(fsys fs.FS, reference Reference) ([]byte, error) { + if err := validateReference(reference); err != nil { + return nil, err } + data, err := fs.ReadFile(fsys, reference.File) + if err != nil { + return nil, fmt.Errorf("read referenced file %q: %w", reference.File, err) + } + return data, nil } -// SourceFiles returns managed files and external references that can affect the -// current sync plan. Invalid managed files remain watched so fixing them triggers sync. -func SourceFiles(repositoryRoot string) ([]string, error) { - managedRoot := filepath.Join(repositoryRoot, syncdomain.RootDir) - var files []string - err := filepath.WalkDir(managedRoot, func(filePath string, entry os.DirEntry, walkErr error) error { - if errors.Is(walkErr, os.ErrNotExist) { - return nil - } - if walkErr != nil { - return walkErr - } - if entry.IsDir() { - return nil - } - - relative, err := filepath.Rel(repositoryRoot, filePath) - if err != nil { - return err - } - managedRelative, err := filepath.Rel(managedRoot, filePath) - if err != nil { - return err - } - // Every file below a project is a potential current or future sync - // input. Root-level files are package-owned metadata such as the manifest. - if filepath.Dir(managedRelative) != "." { - files = append(files, filepath.ToSlash(relative)) - } - if !strings.HasSuffix(entry.Name(), variationFileSuffix) { - return nil - } - - content, err := os.ReadFile(filePath) - if err != nil { - return nil - } - // Reference discovery is best effort. The compiler will report detailed - // syntax errors; the watcher only needs valid references it can follow. - var metadata variationFrontMatter - if _, err := parseYAMLFrontMatter(content, &metadata); err != nil { - return nil - } - if metadata.Ref != nil && validateReference(*metadata.Ref) == nil { - files = append(files, metadata.Ref.File) - } - return nil - }) - if errors.Is(err, os.ErrNotExist) { - return nil, nil +func readWorkspaceReference(repositoryRoot string, reference Reference) ([]byte, error) { + target, err := resolveReferencePath(repositoryRoot, reference) + if err != nil { + return nil, err } + data, err := os.ReadFile(target) if err != nil { - return nil, fmt.Errorf("find sync source files: %w", err) + return nil, fmt.Errorf("read referenced file %q: %w", reference.File, err) } - - slices.Sort(files) - return slices.Compact(files), nil + return data, nil } diff --git a/internal/sync/local/reference_test.go b/internal/sync/local/reference_test.go index 9c0054e1..a1cbc3fc 100644 --- a/internal/sync/local/reference_test.go +++ b/internal/sync/local/reference_test.go @@ -1,7 +1,6 @@ package local import ( - "encoding/json" "os" "path/filepath" "testing" @@ -72,8 +71,7 @@ func TestCompileWorkspaceReadsLinkedPrompt(t *testing.T) { require.NoError(t, err) require.Len(t, resources, 1) - var variation syncdomain.Variation - require.NoError(t, json.Unmarshal(resources[0].Payload, &variation)) + variation := resources[0].Variation require.Equal(t, "Be helpful.", variation.Instructions) } @@ -118,8 +116,7 @@ func TestReplaceVariationsUpdatesLinkedWrapperAndPrompt(t *testing.T) { compiled, err := CompileWorkspace(root) require.NoError(t, err) resource := requireVariationResource(t, compiled, "support/default") - var localVariation syncdomain.Variation - require.NoError(t, json.Unmarshal(resource.Payload, &localVariation)) + localVariation := resource.Variation serverFingerprint, err := syncdomain.FingerprintVariation("project", "support/default", serverVariation) require.NoError(t, err) localFingerprint, err := syncdomain.FingerprintVariation("project", "support/default", localVariation) diff --git a/internal/sync/local/render.go b/internal/sync/local/render.go deleted file mode 100644 index a1826eef..00000000 --- a/internal/sync/local/render.go +++ /dev/null @@ -1,250 +0,0 @@ -package local - -import ( - "bytes" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - - syncdomain "github.com/launchdarkly/ldcli/internal/sync" - "gopkg.in/yaml.v3" -) - -// RenderVariations validates and renders variation files without writing them. -func (store Store) RenderVariations(resources []VariationFile) ([]RenderedVariationFile, error) { - rendered := make([]RenderedVariationFile, 0, len(resources)) - seenPaths := make(map[string]struct{}, len(resources)) - - for _, resource := range resources { - absolutePath, err := store.variationPath(resource.ProjectKey, resource.ConfigKey, resource.Variation.Key) - if err != nil { - return nil, err - } - if _, duplicate := seenPaths[absolutePath]; duplicate { - return nil, fmt.Errorf("variation %q was selected more than once", resource.Variation.Key) - } - seenPaths[absolutePath] = struct{}{} - - content, err := marshalVariationFile(resource) - if err != nil { - return nil, err - } - rendered = append(rendered, RenderedVariationFile{ - Path: filepath.ToSlash(strings.TrimPrefix(absolutePath, store.root+string(filepath.Separator))), - Content: content, - }) - } - return rendered, nil -} - -// RenderResources validates and renders dependency files before their -// consuming variation wrappers. -func (store Store) RenderResources(resources []VariationFile) ([]RenderedVariationFile, error) { - variations, err := store.RenderVariations(resources) - if err != nil { - return nil, err - } - attachments, err := renderAttachmentFiles(resources) - if err != nil { - return nil, err - } - return append(attachments, variations...), nil -} - -// createVariations writes a prevalidated batch and rolls back files created -// before the first failure. -func (store Store) createVariations(resources []VariationFile) (Creation, error) { - // Render the complete batch first so validation failures cannot leave a - // partially created workspace. - files, err := store.RenderResources(resources) - if err != nil { - return Creation{}, err - } - - var createdPaths []string - for _, file := range files { - absolutePath := filepath.Join(store.root, filepath.FromSlash(file.Path)) - created, err := createResourceFile(store.root, absolutePath, file.Content) - if err != nil { - rollbackErr := store.RollbackCreation(Creation{CreatedPaths: createdPaths}) - return Creation{}, errors.Join(err, rollbackErr) - } - if created { - createdPaths = append(createdPaths, file.Path) - } - } - - var variationPaths []string - for _, file := range files { - if strings.HasSuffix(file.Path, variationFileSuffix) { - variationPaths = append(variationPaths, file.Path) - } - } - return Creation{VariationPaths: variationPaths, CreatedPaths: createdPaths}, nil -} - -// createResourceFile preserves an existing dependency file so adding another -// consuming variation never overwrites local edits. -func createResourceFile(root, path string, data []byte) (bool, error) { - _, err := os.Stat(path) - switch { - case err == nil: - if err := rejectSymlinkedPath(root, path); err != nil { - return false, err - } - if strings.HasSuffix(path, variationFileSuffix) { - return false, fmt.Errorf("%w: %s", ErrVariationExists, path) - } - return false, nil - case !errors.Is(err, os.ErrNotExist): - return false, fmt.Errorf("inspect resource file %s: %w", filepath.Base(path), err) - } - if err := createFile(root, path, data); err != nil { - return false, err - } - return true, nil -} - -// marshalVariationFile converts the canonical variation into front matter and -// the mode-specific prompt body used by local wrapper files. -func marshalVariationFile(resource VariationFile) ([]byte, error) { - if !resource.Variation.Mode.Valid() { - return nil, fmt.Errorf("variation %q has unsupported mode %q", resource.Variation.Key, resource.Variation.Mode) - } - if err := resource.Variation.NormalizeAttachments(); err != nil { - return nil, fmt.Errorf("variation %q attachments: %w", resource.Variation.Key, err) - } - switch resource.Variation.Mode { - case syncdomain.VariationModeAgent: - if len(resource.Variation.Messages) != 0 { - return nil, fmt.Errorf("agent variation %q cannot contain messages", resource.Variation.Key) - } - case syncdomain.VariationModeCompletion: - if resource.Variation.Instructions != "" { - return nil, fmt.Errorf("completion variation %q cannot contain instructions", resource.Variation.Key) - } - } - - var frontMatter bytes.Buffer - encoder := yaml.NewEncoder(&frontMatter) - encoder.SetIndent(2) - err := encoder.Encode(variationFrontMatter{ - FormatVersion: 1, - Upsert: resource.Upsert, - Ref: resource.Ref, - Variation: resource.Variation, - }) - if err != nil { - return nil, fmt.Errorf("marshal variation %q: %w", resource.Variation.Key, err) - } - if err := encoder.Close(); err != nil { - return nil, fmt.Errorf("marshal variation %q: %w", resource.Variation.Key, err) - } - - var file bytes.Buffer - file.WriteString("---\n") - file.Write(frontMatter.Bytes()) - file.WriteString("---\n") - if resource.Ref != nil { - // Referenced wrappers contain metadata only. Keeping the body empty - // prevents two local sources from competing for the same content. - if err := validateReference(*resource.Ref); err != nil { - return nil, err - } - return file.Bytes(), nil - } - if resource.Variation.Mode == syncdomain.VariationModeAgent { - if instructions := syncdomain.NormalizePromptText(resource.Variation.Instructions); instructions != "" { - _, _ = fmt.Fprintf(&file, "\n%s\n", instructions) - } - return file.Bytes(), nil - } - - for _, message := range resource.Variation.Messages { - if !validMessageRole(message.Role) { - return nil, fmt.Errorf("variation %q has unsupported message role %q", resource.Variation.Key, message.Role) - } - content := escapeMessageContent(syncdomain.NormalizePromptText(message.Content), message.Role) - _, _ = fmt.Fprintf(&file, "\n<%s>\n%s\n\n", message.Role, content, message.Role) - } - return file.Bytes(), nil -} - -// validMessageRole reports whether the wrapper syntax supports a role. -func validMessageRole(role string) bool { - return role == "system" || role == "user" || role == "assistant" -} - -// escapeMessageContent protects literal role delimiters and backslashes so a -// rendered completion prompt can always be parsed back losslessly. -func escapeMessageContent(content, role string) string { - content = strings.ReplaceAll(content, `\`, `\\`) - content = strings.ReplaceAll(content, "<"+role+">", `<\`+role+">") - return strings.ReplaceAll(content, "", `<\/`+role+">") -} - -// createFile stages content beside its destination and hard-links it into -// place, which guarantees an existing wrapper is never overwritten. -func createFile(root, path string, data []byte) error { - if err := rejectSymlinkedPath(root, path); err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return fmt.Errorf("create variation directory: %w", err) - } - - temp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-") - if err != nil { - return fmt.Errorf("stage variation %s: %w", filepath.Base(path), err) - } - tempPath := temp.Name() - defer func() { _ = os.Remove(tempPath) }() - - // The temporary file is fully written before publication. A hard link is - // then an atomic create-if-absent operation on the same filesystem. - if err := temp.Chmod(0o644); err != nil { - _ = temp.Close() - return fmt.Errorf("set variation permissions: %w", err) - } - if _, err := temp.Write(data); err != nil { - _ = temp.Close() - return fmt.Errorf("write variation %s: %w", filepath.Base(path), err) - } - if err := temp.Close(); err != nil { - return fmt.Errorf("close variation %s: %w", filepath.Base(path), err) - } - if err := os.Link(tempPath, path); err != nil { - if errors.Is(err, os.ErrExist) { - return fmt.Errorf("%w: %s", ErrVariationExists, path) - } - return fmt.Errorf("create variation %s: %w", filepath.Base(path), err) - } - return nil -} - -// rejectSymlinkedPath prevents managed writes from being redirected through -// an existing file or parent-directory symlink. -func rejectSymlinkedPath(root, target string) error { - relative, err := filepath.Rel(root, target) - if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return fmt.Errorf("managed path %s is outside %s", target, root) - } - - current := root - for _, component := range append([]string{""}, strings.Split(relative, string(filepath.Separator))...) { - current = filepath.Join(current, component) - info, err := os.Lstat(current) - if errors.Is(err, os.ErrNotExist) { - return nil - } - if err != nil { - return fmt.Errorf("inspect managed path %s: %w", current, err) - } - if info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("symbolic links are not supported: %s", current) - } - } - return nil -} diff --git a/internal/sync/local/replace.go b/internal/sync/local/replace.go index 4c715d42..0a08f2c5 100644 --- a/internal/sync/local/replace.go +++ b/internal/sync/local/replace.go @@ -10,335 +10,297 @@ import ( syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" ) -type stagedVariation struct { - relativePath string - destinationPath string - originalContent []byte - replacementContent []byte - mode os.FileMode - stagedPath string - originalExists bool - report bool +// stagedFile is one file in a replace transaction. The original content lets +// the transaction detect a concurrent edit and roll back a failure. +type stagedFile struct { + displayPath string + path string + original []byte + originalExists bool + replacement []byte + mode os.FileMode + stagedPath string + isVariationFile bool } -// ReplaceVariations transactionally replaces a batch of wrappers and -// referenced files, rolling back committed paths if a later rename fails. -func (store Store) ReplaceVariations(resources []VariationReplacement) ([]string, error) { - replacements, err := store.prepareReplacements(resources) +// ReplaceVariations replaces a batch of variation files in one transaction. +// The transaction also writes each linked file and each tool and skill file +// that the variations use. ReplaceVariations returns the replaced variation +// paths. +// +// The transaction has three steps. First, it writes every new file to a +// temporary path. Then it makes sure that no original changed during the +// sync. Last, it renames each temporary file into place, and restores the +// originals if a rename fails. +func (store Store) ReplaceVariations(replacements []VariationReplacement) ([]string, error) { + files, err := store.prepareReplacements(replacements) if err != nil { return nil, err } - if err := stageReplacements(replacements); err != nil { + if err := stageFiles(files); err != nil { return nil, err } - defer removeStagedVariations(replacements) + defer removeStagedFiles(files) - // Staging can take long enough for an editor to change a source file. - // Recheck every source before replacing any of them. - if err := verifyReplacementSources(replacements); err != nil { + if err := verifyOriginals(files); err != nil { return nil, err } - if err := commitReplacements(replacements); err != nil { + if err := commitFiles(files); err != nil { return nil, err } - return replacementPaths(replacements), nil + + var paths []string + for _, file := range files { + if file.isVariationFile { + paths = append(paths, file.displayPath) + } + } + return paths, nil } -// prepareReplacements renders every destination before any file is changed. -func (store Store) prepareReplacements(resources []VariationReplacement) ([]stagedVariation, error) { - replacements := make([]stagedVariation, 0, len(resources)) - seenPaths := make(map[string]struct{}, len(resources)) +// ReplaceFileAtomically replaces the file at path only if its content is +// still original. displayPath names the file in errors. +func ReplaceFileAtomically(path, displayPath string, original, replacement []byte, mode os.FileMode) error { + file := stagedFile{ + displayPath: displayPath, path: path, original: original, originalExists: true, + replacement: replacement, mode: mode, + } + files := []stagedFile{file} + if err := stageFiles(files); err != nil { + return err + } + defer removeStagedFiles(files) - for _, resource := range resources { - existing, err := store.inspectVariation(resource.ProjectKey, resource.ConfigKey, resource.Variation.Key) - if err != nil && resource.CreateIfMissing && errors.Is(err, os.ErrNotExist) { - absolutePath, pathErr := store.variationPath(resource.ProjectKey, resource.ConfigKey, resource.Variation.Key) - if pathErr != nil { - return nil, pathErr - } - relativePath, pathErr := filepath.Rel(store.root, absolutePath) - if pathErr != nil { - return nil, fmt.Errorf("resolve variation path %q: %w", resource.Variation.Key, pathErr) - } - existing = existingVariation{ - relativePath: filepath.ToSlash(relativePath), - absolutePath: absolutePath, - mode: 0o644, - frontMatter: variationFrontMatter{Upsert: true}, - } - err = nil + if err := verifyOriginals(files); err != nil { + return err + } + if err := os.Rename(files[0].stagedPath, path); err != nil { + return fmt.Errorf("replace file %s: %w", displayPath, err) + } + return nil +} + +// prepareReplacements renders every file in the transaction before the +// transaction changes a file. +func (store Store) prepareReplacements(replacements []VariationReplacement) ([]stagedFile, error) { + var files []stagedFile + seen := make(map[string]struct{}) + add := func(file stagedFile) error { + if _, duplicate := seen[file.path]; duplicate { + return fmt.Errorf("file %q was selected more than once", file.displayPath) + } + seen[file.path] = struct{}{} + files = append(files, file) + return nil + } + + attachmentSources := make([]VariationFile, 0, len(replacements)) + for _, replacement := range replacements { + existing, err := store.readVariation(replacement.ProjectKey, replacement.ConfigKey, replacement.Variation.Key) + if replacement.CreateIfMissing && errors.Is(err, os.ErrNotExist) { + existing, err = missingVariation(replacement) } if err != nil { return nil, err } - if _, duplicate := seenPaths[existing.absolutePath]; duplicate { - return nil, fmt.Errorf("variation %q was selected more than once", resource.Variation.Key) - } - seenPaths[existing.absolutePath] = struct{}{} - - // Upsert and ref describe the local mapping, not server state. Preserve - // them while replacing only the synchronized variation fields. - content, err := marshalVariationFile(VariationFile{ - ProjectKey: resource.ProjectKey, - ConfigKey: resource.ConfigKey, + // Upsert and ref describe the local file, not LaunchDarkly. Keep them. + content, err := renderVariationFile(VariationFile{ + ProjectKey: replacement.ProjectKey, + ConfigKey: replacement.ConfigKey, Upsert: existing.frontMatter.Upsert, Ref: existing.frontMatter.Ref, - Variation: resource.Variation, + Variation: replacement.Variation, }) if err != nil { return nil, err } - replacements = append(replacements, stagedVariation{ - relativePath: existing.relativePath, - destinationPath: existing.absolutePath, - originalContent: existing.content, - replacementContent: content, - mode: existing.mode, - originalExists: existing.exists, - report: true, - }) - - if existing.frontMatter.Ref == nil { - continue - } - // Add a linked wrapper and its external source to the same staged - // transaction so a commit failure rolls back both paths. - reference := *existing.frontMatter.Ref - referencePath, err := resolveReferencePath(store.repositoryRoot, reference) - if err != nil { + if err := add(stagedFile{ + displayPath: existing.relativePath, path: store.absolute(existing.relativePath), + original: existing.content, originalExists: existing.exists, replacement: content, mode: existing.mode, + isVariationFile: true, + }); err != nil { return nil, err } - if _, duplicate := seenPaths[referencePath]; duplicate { - return nil, fmt.Errorf("referenced file %q was selected more than once", reference.File) + + if ref := existing.frontMatter.Ref; ref != nil { + linked, err := store.prepareLinkedFile(*ref, replacement) + if err != nil { + return nil, err + } + if err := add(linked); err != nil { + return nil, err + } } - seenPaths[referencePath] = struct{}{} + attachmentSources = append(attachmentSources, VariationFile{ + ProjectKey: replacement.ProjectKey, Variation: replacement.Variation, + }) + } - referenceContent, err := syncreference.Render(reference.Format, resource.Variation) - if err != nil { + attachments, err := store.prepareAttachmentFiles(attachmentSources) + if err != nil { + return nil, err + } + for _, attachment := range attachments { + if err := add(attachment); err != nil { return nil, err } - originalContent, err := os.ReadFile(referencePath) - if err != nil { - return nil, fmt.Errorf("read referenced file %q: %w", reference.File, err) - } - info, err := os.Stat(referencePath) - if err != nil { - return nil, fmt.Errorf("stat referenced file %q: %w", reference.File, err) - } - replacements = append(replacements, stagedVariation{ - relativePath: reference.File, - destinationPath: referencePath, - originalContent: originalContent, - replacementContent: referenceContent, - mode: info.Mode().Perm(), - originalExists: true, - }) } + return files, nil +} - attachmentFiles := make([]VariationFile, 0, len(resources)) - for _, resource := range resources { - attachmentFiles = append(attachmentFiles, VariationFile{ - ProjectKey: resource.ProjectKey, - Variation: resource.Variation, - }) +// missingVariation describes a variation file that does not exist yet. A new +// file has upsert, so that a later sync can create the variation again. +func missingVariation(replacement VariationReplacement) (existingVariation, error) { + relativePath, err := variationPath(replacement.ProjectKey, replacement.ConfigKey, replacement.Variation.Key) + if err != nil { + return existingVariation{}, err + } + return existingVariation{ + relativePath: relativePath, + mode: 0o644, + frontMatter: variationFrontMatter{Upsert: true}, + }, nil +} + +// prepareLinkedFile renders the new content of the external file that a +// linked variation uses. +func (store Store) prepareLinkedFile(ref Reference, replacement VariationReplacement) (stagedFile, error) { + path, err := resolveReferencePath(store.repositoryRoot, ref) + if err != nil { + return stagedFile{}, err + } + content, err := syncreference.Render(ref.Format, replacement.Variation) + if err != nil { + return stagedFile{}, err + } + info, err := os.Stat(path) + if err != nil { + return stagedFile{}, fmt.Errorf("inspect referenced file %q: %w", ref.File, err) } - files, err := renderAttachmentFiles(attachmentFiles) + original, err := os.ReadFile(path) + if err != nil { + return stagedFile{}, fmt.Errorf("read referenced file %q: %w", ref.File, err) + } + return stagedFile{ + displayPath: ref.File, path: path, original: original, originalExists: true, + replacement: content, mode: info.Mode().Perm(), + }, nil +} + +// prepareAttachmentFiles renders each tool and skill file that changed. A new +// file is created. A tool file keeps its local upsert flag. +func (store Store) prepareAttachmentFiles(files []VariationFile) ([]stagedFile, error) { + rendered, err := renderAttachmentFiles(files) if err != nil { return nil, err } - for _, file := range files { - destination := filepath.Join(store.root, filepath.FromSlash(file.Path)) - if err := rejectSymlinkedPath(store.root, destination); err != nil { + + var staged []stagedFile + for _, file := range rendered { + path := store.absolute(file.Path) + if err := rejectSymlinkedPath(store.root, path); err != nil { return nil, err } - if _, duplicate := seenPaths[destination]; duplicate { - return nil, fmt.Errorf("resource file %q was selected more than once", file.Path) - } - seenPaths[destination] = struct{}{} - original, err := os.ReadFile(destination) - exists := err == nil - if err != nil && !errors.Is(err, os.ErrNotExist) { + next := stagedFile{displayPath: file.Path, path: path, replacement: file.Content, mode: 0o644} + original, err := os.ReadFile(path) + switch { + case errors.Is(err, os.ErrNotExist): + case err != nil: return nil, fmt.Errorf("read attachment %q: %w", file.Path, err) - } - replacement := file.Content - mode := os.FileMode(0o644) - if exists { - replacement, err = preserveToolUpsert(file.Path, original, replacement) + default: + info, err := os.Stat(path) if err != nil { - return nil, err + return nil, fmt.Errorf("inspect attachment %q: %w", file.Path, err) } - info, err := os.Stat(destination) - if err != nil { - return nil, fmt.Errorf("stat attachment %q: %w", file.Path, err) + if next.replacement, err = preserveToolUpsert(file.Path, original, file.Content); err != nil { + return nil, err } - mode = info.Mode().Perm() + next.original, next.originalExists, next.mode = original, true, info.Mode().Perm() } - if exists && bytes.Equal(original, replacement) { + if next.originalExists && bytes.Equal(next.original, next.replacement) { continue } - replacements = append(replacements, stagedVariation{ - relativePath: file.Path, - destinationPath: destination, - originalContent: original, - replacementContent: replacement, - mode: mode, - originalExists: exists, - }) + staged = append(staged, next) } - return replacements, nil + return staged, nil } -// stageReplacements writes every replacement to its destination directory -// before the first original file is changed. -func stageReplacements(replacements []stagedVariation) error { - for index := range replacements { - if err := os.MkdirAll(filepath.Dir(replacements[index].destinationPath), 0o755); err != nil { - removeStagedVariations(replacements) - return fmt.Errorf("create resource directory %s: %w", replacements[index].relativePath, err) +// stageFiles writes each replacement to a temporary file beside its destination. +func stageFiles(files []stagedFile) error { + for index := range files { + if err := os.MkdirAll(filepath.Dir(files[index].path), 0o755); err != nil { + removeStagedFiles(files) + return fmt.Errorf("create resource directory %s: %w", files[index].displayPath, err) } - stagedPath, err := stageReplacement(replacements[index]) + stagedPath, err := writeTempFile(files[index].path, files[index].replacement, files[index].mode) if err != nil { - removeStagedVariations(replacements) + removeStagedFiles(files) return err } - replacements[index].stagedPath = stagedPath + files[index].stagedPath = stagedPath } return nil } -// verifyReplacementSources detects editor changes made after preflight and -// before commit so sync never overwrites unreviewed content. -func verifyReplacementSources(replacements []stagedVariation) error { - for _, replacement := range replacements { - current, err := os.ReadFile(replacement.destinationPath) - if !replacement.originalExists && errors.Is(err, os.ErrNotExist) { +// verifyOriginals makes sure that no file changed after the transaction read +// it. An editor can save a file while sync waits for review. +func verifyOriginals(files []stagedFile) error { + for _, file := range files { + current, err := os.ReadFile(file.path) + if !file.originalExists && errors.Is(err, os.ErrNotExist) { continue } if err != nil { - return fmt.Errorf("recheck variation %s: %w", replacement.relativePath, err) - } - if !bytes.Equal(current, replacement.originalContent) { - return fmt.Errorf("variation %s changed while syncing", replacement.relativePath) + return fmt.Errorf("recheck variation %s: %w", file.displayPath, err) } - } - return nil -} - -// commitReplacements renames staged files into place and restores already -// replaced files if a later rename fails. -func commitReplacements(replacements []stagedVariation) error { - var replaced []stagedVariation - for _, replacement := range replacements { - // Each rename is atomic, but the batch is not. Keep the committed prefix - // so it can be restored if a later destination fails. - if err := os.Rename(replacement.stagedPath, replacement.destinationPath); err != nil { - return errors.Join(fmt.Errorf("replace variation %s: %w", replacement.relativePath, err), rollbackVariations(replaced)) + if !bytes.Equal(current, file.original) { + return fmt.Errorf("variation %s changed while syncing", file.displayPath) } - replaced = append(replaced, replacement) } return nil } -// replacementPaths reports wrapper paths while hiding referenced-file details. -func replacementPaths(replacements []stagedVariation) []string { - var paths []string - for _, replacement := range replacements { - if replacement.report { - paths = append(paths, replacement.relativePath) +// commitFiles renames each staged file into place. Each rename is atomic, but +// the batch is not, so a failure restores the files that commitFiles replaced. +func commitFiles(files []stagedFile) error { + for index, file := range files { + if err := os.Rename(file.stagedPath, file.path); err != nil { + return errors.Join(fmt.Errorf("replace variation %s: %w", file.displayPath, err), restoreFiles(files[:index])) } } - return paths -} - -// ReplaceFileAtomically replaces an existing file only when its content still -// matches the caller's snapshot. The replacement is durably staged beside the -// destination before one atomic rename publishes it. -func ReplaceFileAtomically(path, displayPath string, originalContent, replacementContent []byte, mode os.FileMode) error { - replacement := stagedVariation{ - relativePath: displayPath, destinationPath: path, originalContent: originalContent, - replacementContent: replacementContent, mode: mode, - } - stagedPath, err := stageReplacement(replacement) - if err != nil { - return err - } - defer func() { _ = os.Remove(stagedPath) }() - - if err := verifyReplacementSources([]stagedVariation{replacement}); err != nil { - return err - } - if err := os.Rename(stagedPath, path); err != nil { - return fmt.Errorf("replace file %s: %w", displayPath, err) - } return nil } -// stageReplacement durably writes one temporary file beside its destination, -// preserving the destination's permission bits. -func stageReplacement(replacement stagedVariation) (string, error) { - temp, err := os.CreateTemp(filepath.Dir(replacement.destinationPath), "."+filepath.Base(replacement.destinationPath)+".tmp-") - if err != nil { - return "", fmt.Errorf("stage variation %s: %w", replacement.relativePath, err) - } - tempPath := temp.Name() - closeWithError := func(err error) (string, error) { - _ = temp.Close() - _ = os.Remove(tempPath) - return "", err - } - - if err := temp.Chmod(replacement.mode); err != nil { - return closeWithError(fmt.Errorf("set variation permissions %s: %w", replacement.relativePath, err)) - } - if _, err := temp.Write(replacement.replacementContent); err != nil { - return closeWithError(fmt.Errorf("write staged variation %s: %w", replacement.relativePath, err)) - } - if err := temp.Sync(); err != nil { - return closeWithError(fmt.Errorf("sync staged variation %s: %w", replacement.relativePath, err)) - } - if err := temp.Close(); err != nil { - _ = os.Remove(tempPath) - return "", fmt.Errorf("close staged variation %s: %w", replacement.relativePath, err) - } - return tempPath, nil -} - -// removeStagedVariations cleans up temporary files left after success or failure. -func removeStagedVariations(replacements []stagedVariation) { - for _, replacement := range replacements { - if replacement.stagedPath != "" { - _ = os.Remove(replacement.stagedPath) - } - } -} - -// rollbackVariations restores original bytes in reverse commit order. -func rollbackVariations(replacements []stagedVariation) error { - var rollbackErr error - // Reverse order mirrors the commit sequence and minimizes time spent in a - // partially restored state. - for index := len(replacements) - 1; index >= 0; index-- { - replacement := replacements[index] - if !replacement.originalExists { - if err := os.Remove(replacement.destinationPath); err != nil && !errors.Is(err, os.ErrNotExist) { - rollbackErr = errors.Join(rollbackErr, fmt.Errorf("roll back variation %s: %w", replacement.relativePath, err)) +// restoreFiles puts back the original content of each file, in reverse order. +func restoreFiles(files []stagedFile) error { + var failures []error + for index := len(files) - 1; index >= 0; index-- { + file := files[index] + if !file.originalExists { + if err := os.Remove(file.path); err != nil && !errors.Is(err, os.ErrNotExist) { + failures = append(failures, fmt.Errorf("roll back variation %s: %w", file.displayPath, err)) } continue } - tempPath, err := stageReplacement(stagedVariation{ - relativePath: replacement.relativePath, destinationPath: replacement.destinationPath, - replacementContent: replacement.originalContent, mode: replacement.mode, - }) + tempPath, err := writeTempFile(file.path, file.original, file.mode) if err == nil { - err = os.Rename(tempPath, replacement.destinationPath) - if err != nil { + if err = os.Rename(tempPath, file.path); err != nil { _ = os.Remove(tempPath) } } if err != nil { - rollbackErr = errors.Join(rollbackErr, fmt.Errorf("roll back variation %s: %w", replacement.relativePath, err)) + failures = append(failures, fmt.Errorf("roll back variation %s: %w", file.displayPath, err)) + } + } + return errors.Join(failures...) +} + +// removeStagedFiles removes the temporary files that remain after a commit or a failure. +func removeStagedFiles(files []stagedFile) { + for _, file := range files { + if file.stagedPath != "" { + _ = os.Remove(file.stagedPath) } } - return rollbackErr } diff --git a/internal/sync/local/sources.go b/internal/sync/local/sources.go new file mode 100644 index 00000000..ac58271b --- /dev/null +++ b/internal/sync/local/sources.go @@ -0,0 +1,72 @@ +package local + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +// SourceFiles returns every file that can change the sync plan, relative to +// the repository root. The list has each file in a project directory and +// each file that a variation links to. A file that is not valid stays in the +// list, so that a fix to the file starts a new sync in watch mode. +func SourceFiles(repositoryRoot string) ([]string, error) { + managedRoot := filepath.Join(repositoryRoot, syncdomain.RootDir) + var files []string + err := filepath.WalkDir(managedRoot, func(filePath string, entry os.DirEntry, err error) error { + switch { + case errors.Is(err, os.ErrNotExist): + return nil + case err != nil: + return err + case entry.IsDir(): + return nil + } + + relative, err := filepath.Rel(repositoryRoot, filePath) + if err != nil { + return err + } + // A file directly in the managed directory is not in a project. + if filepath.Dir(filePath) != managedRoot { + files = append(files, filepath.ToSlash(relative)) + } + if strings.HasSuffix(entry.Name(), variationFileSuffix) { + if reference, ok := linkedFile(filePath); ok { + files = append(files, reference) + } + } + return nil + }) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("find sync source files: %w", err) + } + + slices.Sort(files) + return slices.Compact(files), nil +} + +// linkedFile returns the file that a variation file links to. It ignores a +// variation file that it cannot parse, because compile reports that error. +func linkedFile(file string) (string, bool) { + content, err := os.ReadFile(file) + if err != nil { + return "", false + } + var metadata variationFrontMatter + if _, err := parseYAMLFrontMatter(content, &metadata); err != nil { + return "", false + } + if metadata.Ref == nil || validateReference(*metadata.Ref) != nil { + return "", false + } + return metadata.Ref.File, true +} diff --git a/internal/sync/local/store.go b/internal/sync/local/store.go index a70486be..96ab5171 100644 --- a/internal/sync/local/store.go +++ b/internal/sync/local/store.go @@ -1,3 +1,6 @@ +// Package local reads and writes the sync files in the .launchdarkly +// directory of a Git repository. Every write is atomic for each file and +// rolls back the batch when a later file fails. package local import ( @@ -11,10 +14,10 @@ import ( syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) -// ErrVariationExists reports that a create would overwrite a local wrapper. +// ErrVariationExists reports that a create would overwrite a variation file. var ErrVariationExists = errors.New("variation already exists locally") -// VariationFile contains everything needed to write one local variation wrapper. +// VariationFile is one variation file to create. type VariationFile struct { ProjectKey string ConfigKey string @@ -23,7 +26,8 @@ type VariationFile struct { Variation syncdomain.Variation } -// VariationReplacement identifies a wrapper and the state to write. +// VariationReplacement is new content for a variation file. If +// CreateIfMissing is true and the file does not exist, the replace creates it. type VariationReplacement struct { ProjectKey string ConfigKey string @@ -31,60 +35,53 @@ type VariationReplacement struct { Variation syncdomain.Variation } -// VariationDeletion identifies an existing wrapper to remove. +// VariationDeletion identifies an existing variation file to delete. type VariationDeletion struct { ProjectKey string ConfigKey string VariationKey string } -// RenderedVariationFile is a repository-relative wrapper ready to write. -type RenderedVariationFile struct { +// RenderedFile is the content of one file and its path relative to the +// managed directory. +type RenderedFile struct { Path string Content []byte } -// Creation records the variation paths returned to users and every file that -// can be safely removed if a later manifest write fails. -type Creation struct { - VariationPaths []string - CreatedPaths []string -} - -// Store reads and writes resources under a repository's .launchdarkly directory. +// Store reads and writes the files in the managed directory of one repository. type Store struct { repositoryRoot string root string } -// NewStore creates a local resource store rooted at a Git repository. +// NewStore creates a store for the repository at repositoryRoot. func NewStore(repositoryRoot string) Store { return Store{repositoryRoot: repositoryRoot, root: filepath.Join(repositoryRoot, syncdomain.RootDir)} } -// Exists reports whether the repository has a .launchdarkly directory. +// Exists reports whether the managed directory exists. func (store Store) Exists() (bool, error) { info, err := os.Stat(store.root) - if errors.Is(err, os.ErrNotExist) { + switch { + case errors.Is(err, os.ErrNotExist): return false, nil - } - if err != nil { + case err != nil: return false, fmt.Errorf("inspect %s: %w", store.root, err) - } - if !info.IsDir() { + case !info.IsDir(): return false, fmt.Errorf("%s exists but is not a directory", store.root) + default: + return true, nil } - return true, nil } -// VariationExists reports whether one local variation wrapper exists. +// VariationExists reports whether a variation file exists. func (store Store) VariationExists(projectKey, configKey, variationKey string) (bool, error) { - path, err := store.variationPath(projectKey, configKey, variationKey) + relativePath, err := variationPath(projectKey, configKey, variationKey) if err != nil { return false, err } - - _, err = os.Stat(path) + _, err = os.Stat(store.absolute(relativePath)) switch { case err == nil: return true, nil @@ -95,105 +92,74 @@ func (store Store) VariationExists(projectKey, configKey, variationKey string) ( } } -// Bootstrap atomically creates a new .launchdarkly directory. -func (store Store) Bootstrap(resources []VariationFile) ([]string, error) { - creation, err := store.BootstrapResources(resources) - return creation.VariationPaths, err -} - -// BootstrapResources creates a workspace and returns its rollback record. -func (store Store) BootstrapResources(resources []VariationFile) (Creation, error) { - if _, err := os.Stat(store.root); err == nil { - return Creation{}, fmt.Errorf("%s already exists", store.root) - } else if !errors.Is(err, os.ErrNotExist) { - return Creation{}, fmt.Errorf("inspect %s: %w", store.root, err) - } - - stagingDirectory, err := os.MkdirTemp(filepath.Dir(store.root), ".launchdarkly.tmp-") - if err != nil { - return Creation{}, fmt.Errorf("create bootstrap staging directory: %w", err) +// RemoveEmptyDirectories removes each empty directory in the managed tree, +// including the managed directory itself. +func (store Store) RemoveEmptyDirectories() error { + var directories []string + err := filepath.WalkDir(store.root, func(path string, entry os.DirEntry, err error) error { + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if entry.IsDir() { + directories = append(directories, path) + } + return nil + }) + if errors.Is(err, os.ErrNotExist) { + return nil } - defer func() { _ = os.RemoveAll(stagingDirectory) }() - - // Build the entire workspace in a sibling directory. The final rename is a - // single commit point because source and destination share a filesystem. - stagedStore := Store{repositoryRoot: store.repositoryRoot, root: stagingDirectory} - creation, err := stagedStore.createVariations(resources) if err != nil { - return Creation{}, err - } - if err := os.Rename(stagingDirectory, store.root); err != nil { - return Creation{}, fmt.Errorf("finish bootstrap: %w", err) + return fmt.Errorf("inspect empty sync directories: %w", err) } - return creation, nil -} -// Add creates a batch of variation wrappers without overwriting existing files. -func (store Store) Add(resources []VariationFile) ([]string, error) { - creation, err := store.AddResources(resources) - return creation.VariationPaths, err -} - -// AddResources creates resources and returns the exact files published by the call. -func (store Store) AddResources(resources []VariationFile) (Creation, error) { - return store.createVariations(resources) -} - -// RollbackCreation removes only files published by the corresponding create. -func (store Store) RollbackCreation(creation Creation) error { - var failures []error - for index := len(creation.CreatedPaths) - 1; index >= 0; index-- { - filePath := filepath.Join(store.root, filepath.FromSlash(creation.CreatedPaths[index])) - if err := os.Remove(filePath); err != nil && !errors.Is(err, os.ErrNotExist) { - failures = append(failures, fmt.Errorf("remove created resource %s: %w", creation.CreatedPaths[index], err)) + // Remove the deepest directories first, so that a parent is empty when + // its turn comes. A directory that still has files returns ENOTEMPTY. + for index := len(directories) - 1; index >= 0; index-- { + err := os.Remove(directories[index]) + if err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, syscall.ENOTEMPTY) { + return fmt.Errorf("remove empty sync directory %s: %w", directories[index], err) } } - if err := store.RemoveEmptyDirectories(); err != nil { - failures = append(failures, err) - } - return errors.Join(failures...) + return nil } +// existingVariation is a variation file on disk and its decoded front matter. +// When exists is false, the file is new and has the default upsert flag. type existingVariation struct { relativePath string - absolutePath string content []byte mode os.FileMode exists bool frontMatter variationFrontMatter } -// inspectVariation reads the wrapper metadata needed by replace and delete -// transactions. -func (store Store) inspectVariation(projectKey, configKey, variationKey string) (existingVariation, error) { - absolutePath, err := store.variationPath(projectKey, configKey, variationKey) +func (store Store) readVariation(projectKey, configKey, variationKey string) (existingVariation, error) { + relativePath, err := variationPath(projectKey, configKey, variationKey) if err != nil { return existingVariation{}, err } + absolutePath := store.absolute(relativePath) - content, err := os.ReadFile(absolutePath) - if err != nil { - return existingVariation{}, fmt.Errorf("read variation %s: %w", variationKey, err) - } info, err := os.Stat(absolutePath) if err != nil { - return existingVariation{}, fmt.Errorf("stat variation %s: %w", variationKey, err) + return existingVariation{}, fmt.Errorf("inspect variation %s: %w", variationKey, err) } if !info.Mode().IsRegular() { return existingVariation{}, fmt.Errorf("variation %s is not a regular file", variationKey) } - - var frontMatter variationFrontMatter - if _, err := parseYAMLFrontMatter(content, &frontMatter); err != nil { - return existingVariation{}, fmt.Errorf("parse variation %s: %w", variationKey, err) + content, err := os.ReadFile(absolutePath) + if err != nil { + return existingVariation{}, fmt.Errorf("read variation %s: %w", variationKey, err) } - if err := validateVariation(filepath.Base(absolutePath), frontMatter); err != nil { - return existingVariation{}, fmt.Errorf("validate variation %s: %w", variationKey, err) + frontMatter, _, err := parseVariationFrontMatter(relativePath, content) + if err != nil { + return existingVariation{}, fmt.Errorf("parse variation %s: %w", variationKey, err) } - return existingVariation{ - relativePath: filepath.ToSlash(strings.TrimPrefix(absolutePath, store.root+string(filepath.Separator))), - absolutePath: absolutePath, + relativePath: relativePath, content: content, mode: info.Mode().Perm(), exists: true, @@ -201,70 +167,32 @@ func (store Store) inspectVariation(projectKey, configKey, variationKey string) }, nil } -// RemoveEmptyDirectories removes empty resource directories left by deletes. -func (store Store) RemoveEmptyDirectories() error { - var directories []string - err := filepath.WalkDir(store.root, func(path string, entry os.DirEntry, walkErr error) error { - if errors.Is(walkErr, os.ErrNotExist) { - return nil - } - if walkErr != nil { - return walkErr - } - if entry.IsDir() { - directories = append(directories, path) - } - return nil - }) - if errors.Is(err, os.ErrNotExist) { - return nil - } - if err != nil { - return fmt.Errorf("inspect empty sync directories: %w", err) - } - - // Remove deepest-first so parent directories become empty as their children - // disappear. ENOTEMPTY is expected when a directory still owns resources. - for index := len(directories) - 1; index >= 0; index-- { - err := os.Remove(directories[index]) - if err == nil || errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTEMPTY) { - continue - } - return fmt.Errorf("remove empty sync directory %s: %w", directories[index], err) - } - return nil +// absolute converts a path relative to the managed directory to an absolute path. +func (store Store) absolute(relativePath string) string { + return filepath.Join(store.root, filepath.FromSlash(relativePath)) } -// variationPath validates every identity component before constructing a path -// beneath the managed workspace. -func (store Store) variationPath(projectKey, configKey, variationKey string) (string, error) { - segments := []struct { - name string - value string - }{ - {name: "project key", value: projectKey}, - {name: "config key", value: configKey}, - {name: "variation key", value: variationKey}, - } - for _, segment := range segments { - if err := validatePathSegment(segment.value); err != nil { - return "", fmt.Errorf("invalid %s %q: %w", segment.name, segment.value, err) - } +// rejectSymlinkedPath makes sure that no component of target is a symbolic +// link, so that a write cannot leave the managed directory. +func rejectSymlinkedPath(root, target string) error { + if !isWithin(root, target) { + return fmt.Errorf("managed path %s is outside %s", target, root) } - return filepath.Join(store.root, projectKey, configsDir, configKey, variationKey+variationFileSuffix), nil -} + relative, _ := filepath.Rel(root, target) -// validatePathSegment rejects traversal, separators, and null bytes before a -// resource identity reaches filesystem APIs. -func validatePathSegment(value string) error { - if value == "" { - return errors.New("must not be empty") - } - if value == "." || value == ".." || strings.ContainsAny(value, `/\`) { - return errors.New("must be a single path segment") - } - if strings.IndexByte(value, 0) >= 0 { - return errors.New("must not contain a null byte") + current := root + for _, component := range append([]string{""}, strings.Split(relative, string(filepath.Separator))...) { + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("inspect managed path %s: %w", current, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("symbolic links are not supported: %s", current) + } } return nil } diff --git a/internal/sync/local/store_test.go b/internal/sync/local/store_test.go index 05cc20e2..bdc9da3f 100644 --- a/internal/sync/local/store_test.go +++ b/internal/sync/local/store_test.go @@ -47,8 +47,9 @@ func TestStore_BootstrapRoundTripsSupportedModes(t *testing.T) { }, } - paths, err := NewStore(root).Bootstrap(resources) + creation, err := NewStore(root).Bootstrap(resources) require.NoError(t, err) + paths := creation.VariationPaths assert.ElementsMatch(t, []string{ "project/configs/completion-config/friendly.prompt.md", "project/configs/agent-config/researcher.prompt.md", @@ -75,9 +76,7 @@ func TestStore_BootstrapRoundTripsSupportedModes(t *testing.T) { compiled, local.ConfigKey+"/"+local.Variation.Key, ) - expected, err := marshalPayload(local.Variation) - require.NoError(t, err) - assert.JSONEq(t, string(expected), string(resource.Payload)) + assertSameVariationJSON(t, local.Variation, resource.Variation) assert.True(t, resource.Upsert) } } @@ -110,8 +109,7 @@ func TestStore_BootstrapNormalizesPromptLineEndings(t *testing.T) { compiled, err := Compile(os.DirFS(root)) require.NoError(t, err) resource := requireVariationResource(t, compiled, "config/"+serverVariation.Key) - var localVariation syncdomain.Variation - require.NoError(t, json.Unmarshal(resource.Payload, &localVariation)) + localVariation := resource.Variation serverFingerprint, err := syncdomain.FingerprintVariation("project", "config/"+serverVariation.Key, serverVariation) require.NoError(t, err) @@ -122,12 +120,12 @@ func TestStore_BootstrapNormalizesPromptLineEndings(t *testing.T) { } } -func TestStore_RenderVariationsMatchesWrittenFile(t *testing.T) { +func TestStore_RenderMatchesWrittenFile(t *testing.T) { root := t.TempDir() store := NewStore(root) resource := localVariation("preview") - rendered, err := store.RenderVariations([]VariationFile{resource}) + rendered, err := store.Render([]VariationFile{resource}) require.NoError(t, err) require.Len(t, rendered, 1) @@ -135,9 +133,9 @@ func TestStore_RenderVariationsMatchesWrittenFile(t *testing.T) { _, err = os.Stat(filepath.Join(root, syncdomain.RootDir)) assert.ErrorIs(t, err, os.ErrNotExist) - paths, err := store.Bootstrap([]VariationFile{resource}) + creation, err := store.Bootstrap([]VariationFile{resource}) require.NoError(t, err) - assert.Equal(t, []string{rendered[0].Path}, paths) + assert.Equal(t, []string{rendered[0].Path}, creation.VariationPaths) content, err := os.ReadFile(filepath.Join( root, syncdomain.RootDir, @@ -252,9 +250,7 @@ func TestStore_ReplaceVariationsPreservesLocalMetadata(t *testing.T) { require.NoError(t, err) resource := requireVariationResource(t, compiled, "config/existing") assert.False(t, resource.Upsert) - expected, err := marshalPayload(server) - require.NoError(t, err) - assert.JSONEq(t, string(expected), string(resource.Payload)) + assertSameVariationJSON(t, server, resource.Variation) info, err := os.Stat(path) require.NoError(t, err) @@ -399,3 +395,12 @@ func requireVariationResource( return syncdomain.SyncedResource{} } + +func assertSameVariationJSON(t *testing.T, expected, actual syncdomain.Variation) { + t.Helper() + expectedJSON, err := json.Marshal(expected) + require.NoError(t, err) + actualJSON, err := json.Marshal(actual) + require.NoError(t, err) + assert.JSONEq(t, string(expectedJSON), string(actualJSON)) +} diff --git a/internal/sync/local/variation.go b/internal/sync/local/variation.go index 7c987b21..d959be2b 100644 --- a/internal/sync/local/variation.go +++ b/internal/sync/local/variation.go @@ -2,30 +2,36 @@ package local import ( "bytes" - "encoding/json" "errors" "fmt" "path" + "slices" "strings" "github.com/adrg/frontmatter" - syncdomain "github.com/launchdarkly/ldcli/internal/sync" - syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" "gopkg.in/yaml.v3" -) -const ( - configsDir = "configs" - variationFileSuffix = ".prompt.md" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" ) -type localFile struct { - ProjectKey string - RelPath string - Data []byte - ReadReference func(Reference) ([]byte, error) - ReadAttachment func(syncdomain.AttachmentKind, string) (syncdomain.Attachment, error) -} +// A variation file has YAML front matter and a prompt body: +// +// --- +// formatVersion: 1 +// mode: completion +// key: default +// name: Default +// --- +// +// +// You are helpful. +// +// +// An agent body is the instructions. A completion body is a list of role +// blocks. A completion body without role blocks is one system message. A +// linked variation has a "ref" field and no body. +const variationFormatVersion = 1 type variationFrontMatter struct { FormatVersion int `yaml:"formatVersion"` @@ -34,159 +40,167 @@ type variationFrontMatter struct { syncdomain.Variation `yaml:",inline"` } -var yamlFrontMatter = frontmatter.NewFormat("---", "---", func(data []byte, destination any) error { - decoder := yaml.NewDecoder(bytes.NewReader(data)) - decoder.KnownFields(true) - - return decoder.Decode(destination) -}) - -// isVariationFile recognizes direct children of a config directory with the -// supported wrapper suffix. -func isVariationFile(relPath string) bool { - if !strings.HasSuffix(relPath, variationFileSuffix) { - return false - } - - dir, file := path.Split(relPath) - dir = strings.TrimSuffix(dir, "/") - - return dir != "" && !strings.Contains(dir, "/") && file != "" +// localFile is one variation file and the readers for the files that it references. +type localFile struct { + ProjectKey string + RelPath string + Data []byte + ReadReference func(Reference) ([]byte, error) + ReadAttachment func(syncdomain.AttachmentKind, string) (syncdomain.Attachment, error) } -// parseVariation combines wrapper metadata with inline or referenced prompt -// content and emits the canonical resource payload. +// parseVariation reads one variation file, including the content of a linked +// file and every attachment that it references. RelPath is relative to the +// configs directory, for example "support/default.prompt.md". func parseVariation(file localFile) (syncdomain.SyncedResource, error) { - var meta variationFrontMatter - body, err := parseYAMLFrontMatter(file.Data, &meta) + meta, body, err := parseVariationFrontMatter(file.RelPath, file.Data) if err != nil { return syncdomain.SyncedResource{}, err } - if err := validateVariation(file.RelPath, meta); err != nil { - return syncdomain.SyncedResource{}, err - } - variation := meta.Variation if meta.Ref != nil { - // The wrapper owns identity and model metadata; the adapter supplies - // only the content fields represented by the external source format. + // The variation file owns the identity and the model settings. The + // linked file owns only the prompt content. if strings.TrimSpace(string(body)) != "" { return syncdomain.SyncedResource{}, errors.New("referenced variation cannot also contain an inline prompt body") } - referencedContent, err := file.ReadReference(*meta.Ref) + content, err := file.ReadReference(*meta.Ref) if err != nil { return syncdomain.SyncedResource{}, err } - if _, err := syncreference.ApplyToVariation(meta.Ref.Format, referencedContent, &variation); err != nil { + if err := syncreference.ApplyToVariation(meta.Ref.Format, content, &variation); err != nil { return syncdomain.SyncedResource{}, err } - stem := strings.TrimSuffix(path.Base(file.RelPath), variationFileSuffix) - if variation.Key != stem { + if stem := variationStem(file.RelPath); variation.Key != stem { return syncdomain.SyncedResource{}, fmt.Errorf("referenced prompt key %q does not match filename %q", variation.Key, stem) } - } else { - // Inline bodies use the simplest representation for each mode: plain - // instructions for agents and explicit role blocks for completions. - switch variation.Mode { - case syncdomain.VariationModeAgent: - variation.Instructions = syncdomain.NormalizePromptText(string(body)) - case syncdomain.VariationModeCompletion: - messages, err := parseCompletionMessages(string(body)) - if err != nil { - return syncdomain.SyncedResource{}, err - } - variation.Messages = messages - } - } - - if err := hydrateLocalAttachments(&variation, file); err != nil { + } else if err := parsePromptBody(string(body), &variation); err != nil { return syncdomain.SyncedResource{}, err } - payload, err := marshalPayload(variation) - if err != nil { + if err := variation.HydrateAttachments(file.ReadAttachment); err != nil { return syncdomain.SyncedResource{}, err } configKey := path.Dir(file.RelPath) - return syncdomain.SyncedResource{ - Kind: syncdomain.KindVariation, - ProjectKey: file.ProjectKey, - LookupKey: configKey + "/" + meta.Key, - Payload: payload, - Attachments: variation.Attachments, - Upsert: meta.Upsert, + Kind: syncdomain.KindVariation, + ProjectKey: file.ProjectKey, + LookupKey: configKey + "/" + variation.Key, + Upsert: meta.Upsert, + Variation: variation, }, nil } -// hydrateLocalAttachments resolves key-only wrapper references into the -// canonical dependency content included in variation fingerprints. -func hydrateLocalAttachments(variation *syncdomain.Variation, file localFile) error { - if err := variation.NormalizeAttachments(); err != nil { - return err +// parseVariationFrontMatter decodes and validates the front matter, and +// returns the remaining body. +func parseVariationFrontMatter(relPath string, data []byte) (variationFrontMatter, []byte, error) { + var meta variationFrontMatter + body, err := parseYAMLFrontMatter(data, &meta) + if err != nil { + return variationFrontMatter{}, nil, err } - variation.Attachments = make([]syncdomain.Attachment, 0, len(variation.Tools)+len(variation.Skills)) - for _, ref := range variation.Tools { - attachment, err := file.ReadAttachment(syncdomain.AttachmentTool, ref.Key) - if err != nil { - return err - } - variation.Attachments = append(variation.Attachments, attachment) + switch { + case meta.FormatVersion == 0: + return variationFrontMatter{}, nil, errors.New("formatVersion is required") + case meta.FormatVersion != variationFormatVersion: + return variationFrontMatter{}, nil, fmt.Errorf("unsupported formatVersion %d", meta.FormatVersion) + } + if err := meta.Variation.Validate(); err != nil { + return variationFrontMatter{}, nil, err } + if stem := variationStem(relPath); meta.Key != stem { + return variationFrontMatter{}, nil, fmt.Errorf("key %q does not match filename %q", meta.Key, stem) + } + return meta, body, nil +} - for _, ref := range variation.Skills { - attachment, err := file.ReadAttachment(syncdomain.AttachmentSkill, ref.Key) - if err != nil { - return err - } - variation.Attachments = append(variation.Attachments, attachment) +// parsePromptBody stores an inline body as agent instructions or as +// completion messages. +func parsePromptBody(body string, variation *syncdomain.Variation) error { + if variation.Mode == syncdomain.VariationModeAgent { + variation.Instructions = syncdomain.NormalizePromptText(body) + return nil + } + messages, err := parseCompletionMessages(body) + if err != nil { + return err } + variation.Messages = messages return nil } -// validateVariation checks the file format and binds the declared key to the filename. -func validateVariation(relPath string, meta variationFrontMatter) error { +// renderVariationFile renders the front matter and the prompt body of one +// variation file. The result parses back to the same variation. +func renderVariationFile(file VariationFile) ([]byte, error) { + variation := file.Variation + if err := variation.Validate(); err != nil { + return nil, fmt.Errorf("variation %q: %w", variation.Key, err) + } + variation.Tools = slices.Clone(variation.Tools) + variation.Skills = slices.Clone(variation.Skills) + variation.SortAttachments() switch { - case meta.FormatVersion == 0: - return errors.New("formatVersion is required") - case meta.FormatVersion != 1: - return fmt.Errorf("unsupported formatVersion %d", meta.FormatVersion) - case meta.Mode == "": - return errors.New("mode is required") - case !meta.Mode.Valid(): - return fmt.Errorf("unsupported mode %q", meta.Mode) - case meta.Key == "": - return errors.New("key is required") - case meta.Name == "": - return errors.New("name is required") + case variation.Mode == syncdomain.VariationModeAgent && len(variation.Messages) != 0: + return nil, fmt.Errorf("agent variation %q cannot contain messages", variation.Key) + case variation.Mode == syncdomain.VariationModeCompletion && variation.Instructions != "": + return nil, fmt.Errorf("completion variation %q cannot contain instructions", variation.Key) + } + if file.Ref != nil { + if err := validateReference(*file.Ref); err != nil { + return nil, err + } } - stem := strings.TrimSuffix(path.Base(relPath), variationFileSuffix) - if stem != meta.Key { - return fmt.Errorf("key %q does not match filename %q", meta.Key, stem) + frontMatter, err := marshalYAML(variationFrontMatter{ + FormatVersion: variationFormatVersion, + Upsert: file.Upsert, + Ref: file.Ref, + Variation: variation, + }) + if err != nil { + return nil, fmt.Errorf("marshal variation %q: %w", variation.Key, err) } - return nil -} + var content bytes.Buffer + content.WriteString("---\n") + content.Write(frontMatter) + content.WriteString("---\n") -// marshalPayload encodes canonical JSON without HTML escaping or a trailing newline. -func marshalPayload(value any) (json.RawMessage, error) { - var buf bytes.Buffer - encoder := json.NewEncoder(&buf) - encoder.SetEscapeHTML(false) - - if err := encoder.Encode(value); err != nil { - return nil, fmt.Errorf("marshal payload: %w", err) + // A linked variation has no body, so that only one file holds the prompt. + switch { + case file.Ref != nil: + case variation.Mode == syncdomain.VariationModeAgent: + if instructions := syncdomain.NormalizePromptText(variation.Instructions); instructions != "" { + fmt.Fprintf(&content, "\n%s\n", instructions) + } + default: + for _, message := range variation.Messages { + if !syncdomain.ValidMessageRole(message.Role) { + return nil, fmt.Errorf("variation %q has unsupported message role %q", variation.Key, message.Role) + } + body := escapeMessageContent(syncdomain.NormalizePromptText(message.Content), message.Role) + fmt.Fprintf(&content, "\n<%s>\n%s\n\n", message.Role, body, message.Role) + } } + return content.Bytes(), nil +} - return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +// variationStem returns the variation key that a file name declares. +func variationStem(relPath string) string { + return strings.TrimSuffix(path.Base(relPath), variationFileSuffix) } -// parseYAMLFrontMatter accepts BOM and common newline variants, decodes strict -// YAML metadata, and returns the remaining prompt body. +var yamlFrontMatter = frontmatter.NewFormat("---", "---", func(data []byte, destination any) error { + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + return decoder.Decode(destination) +}) + +// parseYAMLFrontMatter decodes strict YAML front matter and returns the body +// that follows it. It accepts a byte order mark and Windows line endings. func parseYAMLFrontMatter(data []byte, destination any) ([]byte, error) { source := bytes.TrimLeft(bytes.TrimPrefix(data, []byte("\ufeff")), "\r\n") hasStart := bytes.Equal(source, []byte("---")) || @@ -203,121 +217,19 @@ func parseYAMLFrontMatter(data []byte, destination any) ([]byte, error) { if err != nil { return nil, fmt.Errorf("invalid front matter: %w", err) } - return body, nil } -var messageRoles = []string{"system", "user", "assistant"} - -// parseCompletionMessages parses tagged role blocks, with untagged text treated -// as one system message for a simple authoring experience. -func parseCompletionMessages(body string) ([]syncdomain.Message, error) { - body = syncdomain.NormalizePromptText(body) - if body == "" { - return nil, nil - } - - if _, _, _, ok := nextOpenTag(body, 0); !ok { - // A plain body is a convenient shorthand for the common single-system - // message case. - return []syncdomain.Message{{ - Role: "system", - Content: body, - }}, nil - } - - var messages []syncdomain.Message - cursor := 0 - - for cursor < len(body) { - start, role, contentStart, ok := nextOpenTag(body, cursor) - if !ok { - if strings.TrimSpace(body[cursor:]) != "" { - return nil, errors.New("unexpected text outside message tags") - } - - break - } - if strings.TrimSpace(body[cursor:start]) != "" { - return nil, errors.New("unexpected text outside message tags") - } - - contentEnd, closeEnd, found := matchingClose(body, contentStart, role) - if !found { - return nil, fmt.Errorf("unclosed <%s> tag", role) - } - - // Advance to the byte immediately after the balanced closing tag. The - // next iteration verifies that only whitespace separates messages. - messages = append(messages, syncdomain.Message{ - Role: role, - Content: unescapeMessageContent(syncdomain.NormalizePromptText(body[contentStart:contentEnd]), role), - }) - cursor = closeEnd - } - - return messages, nil -} - -// unescapeMessageContent reverses the delimiter escaping applied during rendering. -func unescapeMessageContent(content, role string) string { - content = strings.ReplaceAll(content, `<\/`+role+">", "") - content = strings.ReplaceAll(content, `<\`+role+">", "<"+role+">") - return strings.ReplaceAll(content, `\\`, `\`) -} - -// nextOpenTag finds the earliest supported role tag at or after an offset. -func nextOpenTag(body string, from int) (start int, role string, contentStart int, ok bool) { - start = -1 - - for _, candidate := range messageRoles { - tag := "<" + candidate + ">" - index := strings.Index(body[from:], tag) - if index < 0 { - continue - } - - absolute := from + index - if start < 0 || absolute < start { - start = absolute - role = candidate - contentStart = absolute + len(tag) - ok = true - } +// marshalYAML encodes front matter with a two-space indent. +func marshalYAML(value any) ([]byte, error) { + var buffer bytes.Buffer + encoder := yaml.NewEncoder(&buffer) + encoder.SetIndent(2) + if err := encoder.Encode(value); err != nil { + return nil, err } - - return start, role, contentStart, ok -} - -// matchingClose finds the balanced closing tag for one role block. -func matchingClose(body string, from int, role string) (contentEnd, closeEnd int, ok bool) { - open := "<" + role + ">" - close := "" - depth := 1 - index := from - - for index < len(body) { - relativeOpen := strings.Index(body[index:], open) - relativeClose := strings.Index(body[index:], close) - if relativeClose < 0 { - return 0, 0, false - } - - if relativeOpen >= 0 && relativeOpen < relativeClose { - depth++ - index += relativeOpen + len(open) - - continue - } - - depth-- - closeAt := index + relativeClose - if depth == 0 { - return closeAt, closeAt + len(close), true - } - - index = closeAt + len(close) + if err := encoder.Close(); err != nil { + return nil, err } - - return 0, 0, false + return buffer.Bytes(), nil } diff --git a/internal/sync/local/variation_test.go b/internal/sync/local/variation_test.go index 6b8f949e..74992125 100644 --- a/internal/sync/local/variation_test.go +++ b/internal/sync/local/variation_test.go @@ -1,7 +1,6 @@ package local import ( - "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -10,12 +9,35 @@ import ( syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) -func TestIsVariationFile(t *testing.T) { - assert.True(t, isVariationFile("my-config/my-variation.prompt.md")) - assert.False(t, isVariationFile("my-variation.prompt.md")) - assert.False(t, isVariationFile("my-config/nested/my-variation.prompt.md")) - assert.False(t, isVariationFile("my-config/my-variation.prompt")) - assert.False(t, isVariationFile("my-config/my-variation.md")) +func TestParseManagedPath(t *testing.T) { + valid := map[string]syncdomain.ResourceID{ + ".launchdarkly/project/configs/config/variation.prompt.md": syncdomain.VariationID("project", "config", "variation"), + ".launchdarkly/project/tools/search.json": { + Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "search", + }, + ".launchdarkly/project/skills/support.md": { + Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "support", + }, + } + for file, expected := range valid { + id, ok := ParseManagedPath(file) + assert.True(t, ok, file) + assert.Equal(t, expected, id, file) + } + + for _, file := range []string{ + ".launchdarkly/project/configs/variation.prompt.md", + ".launchdarkly/project/configs/config/nested/variation.prompt.md", + ".launchdarkly/project/configs/config/variation.prompt", + ".launchdarkly/project/configs/config/.prompt.md", + ".launchdarkly/project/tools/search.md", + ".launchdarkly/project/tools/nested/search.json", + "other/project/configs/config/variation.prompt.md", + ".launchdarkly/manifest.json", + } { + _, ok := ParseManagedPath(file) + assert.False(t, ok, file) + } } func TestParseVariation_UntaggedBodyIsSystemMessage(t *testing.T) { @@ -36,8 +58,7 @@ Just say hello. resource, err := parseVariation(file) require.NoError(t, err) - var payload syncdomain.Variation - require.NoError(t, unmarshalPayload(resource, &payload)) + payload := resource.Variation require.Equal( t, []syncdomain.Message{{Role: "system", Content: "Just say hello."}}, @@ -65,8 +86,7 @@ Use the available capabilities. resource, err := parseVariation(file) require.NoError(t, err) - var payload syncdomain.Variation - require.NoError(t, unmarshalPayload(resource, &payload)) + payload := resource.Variation assert.Equal( t, "Use the available capabilities.\n\nThis tag is part of the instructions.", @@ -91,8 +111,7 @@ func TestParseVariation_ParsesBOMAndCRLF(t *testing.T) { resource, err := parseVariation(file) require.NoError(t, err) - var payload syncdomain.Variation - require.NoError(t, unmarshalPayload(resource, &payload)) + payload := resource.Variation require.Equal( t, []syncdomain.Message{{Role: "system", Content: "Just say hello."}}, @@ -250,7 +269,3 @@ description: Variation description _, err := parseVariation(file) require.ErrorContains(t, err, "invalid front matter") } - -func unmarshalPayload(resource syncdomain.SyncedResource, destination any) error { - return json.Unmarshal(resource.Payload, destination) -} diff --git a/internal/sync/manifest/model.go b/internal/sync/manifest/model.go index 85a493c5..95cddf4e 100644 --- a/internal/sync/manifest/model.go +++ b/internal/sync/manifest/model.go @@ -1,3 +1,6 @@ +// Package manifest records the state of each resource after the last +// successful sync. Sync compares local files and LaunchDarkly with this +// baseline to find which side changed. package manifest import ( @@ -11,14 +14,13 @@ import ( var fingerprintPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) -// Manifest records the common resource state accepted by the last successful -// synchronization. +// Manifest is the baseline of every tracked resource. type Manifest struct { Resources []Resource } -// Resource identifies one tracked resource and its last synchronized -// fingerprint. +// Resource is the baseline of one resource. Version is the version of the +// remote manifest entry, which LaunchDarkly uses for optimistic locking. type Resource struct { ResourceKind syncdomain.Kind ProjectKey string @@ -27,23 +29,26 @@ type Resource struct { Version int } -// ID returns the common identity represented by this manifest entry. +// ID returns the identity of the resource. func (resource Resource) ID() syncdomain.ResourceID { return syncdomain.ResourceID{Kind: resource.ResourceKind, ProjectKey: resource.ProjectKey, LookupKey: resource.LookupKey} } -// New returns an empty current-version manifest. +// New returns an empty manifest. func New() Manifest { return Manifest{Resources: []Resource{}} } -// SetFingerprint records the last synchronized state for one resource. +// Clone returns a manifest that changes independently of the original. +func (manifest Manifest) Clone() Manifest { + return Manifest{Resources: append([]Resource{}, manifest.Resources...)} +} + +// SetFingerprint records the synchronized state of one resource. func (manifest *Manifest) SetFingerprint(id syncdomain.ResourceID, fingerprint string) { - for index := range manifest.Resources { - if manifest.Resources[index].ID() == id { - manifest.Resources[index].Fingerprint = fingerprint - return - } + if index := manifest.index(id); index >= 0 { + manifest.Resources[index].Fingerprint = fingerprint + return } manifest.Resources = append(manifest.Resources, Resource{ ResourceKind: id.Kind, @@ -53,27 +58,29 @@ func (manifest *Manifest) SetFingerprint(id syncdomain.ResourceID, fingerprint s }) } -// SetAttachments records the canonical state of shared dependencies once, -// regardless of how many variations reference them. +// Remove stops tracking one resource. +func (manifest *Manifest) Remove(id syncdomain.ResourceID) { + if index := manifest.index(id); index >= 0 { + manifest.Resources = slices.Delete(manifest.Resources, index, index+1) + } +} + +// SetAttachments records the state of each tool and skill. Many variations +// can share one attachment, but the manifest tracks it once. func (manifest *Manifest) SetAttachments(projectKey string, attachments []syncdomain.Attachment) error { return manifest.setAttachments(projectKey, attachments, true) } -// SetAttachmentsIfMissing establishes baselines for newly tracked -// dependencies without advancing existing baselines past unsynchronized edits. +// SetAttachmentsIfMissing records only the attachments that the manifest does +// not track. An existing baseline does not move past an unsynchronized edit. func (manifest *Manifest) SetAttachmentsIfMissing(projectKey string, attachments []syncdomain.Attachment) error { return manifest.setAttachments(projectKey, attachments, false) } -// setAttachments writes canonical dependency baselines with explicit overwrite behavior. func (manifest *Manifest) setAttachments(projectKey string, attachments []syncdomain.Attachment, overwrite bool) error { for _, attachment := range attachments { - id := syncdomain.ResourceID{ - Kind: syncdomain.Kind(attachment.Kind), - ProjectKey: projectKey, - LookupKey: attachment.Key(), - } - if !overwrite && manifest.has(id) { + id := attachment.ID(projectKey) + if !overwrite && manifest.index(id) >= 0 { continue } fingerprint, err := syncdomain.FingerprintAttachment(projectKey, attachment) @@ -85,89 +92,63 @@ func (manifest *Manifest) setAttachments(projectKey string, attachments []syncdo return nil } -// has reports whether one resource identity is already tracked. -func (manifest Manifest) has(id syncdomain.ResourceID) bool { - return slices.ContainsFunc(manifest.Resources, func(resource Resource) bool { return resource.ID() == id }) -} - -// RemoveUnreferencedAttachments removes dependency baselines that no managed -// variation references after a successful synchronization. -func (manifest *Manifest) RemoveUnreferencedAttachments(referenced map[syncdomain.ResourceID]struct{}) { +// RemoveUnusedAttachments stops tracking each tool and skill that none of the +// variations references. +func (manifest *Manifest) RemoveUnusedAttachments(variations []syncdomain.SyncedResource) { + used := make(map[syncdomain.ResourceID]struct{}) + for _, variation := range variations { + for _, attachment := range variation.Variation.Attachments { + used[attachment.ID(variation.ProjectKey)] = struct{}{} + } + } manifest.Resources = slices.DeleteFunc(manifest.Resources, func(resource Resource) bool { - if resource.ResourceKind != syncdomain.KindTool && resource.ResourceKind != syncdomain.KindSkill { + if resource.ResourceKind == syncdomain.KindVariation { return false } - _, ok := referenced[resource.ID()] + _, ok := used[resource.ID()] return !ok }) } -// Remove deletes one resource from the manifest. -func (manifest *Manifest) Remove(id syncdomain.ResourceID) { - for index, resource := range manifest.Resources { - if resource.ID() == id { - manifest.Resources = append(manifest.Resources[:index], manifest.Resources[index+1:]...) - return - } - } -} - -// Validate checks the manifest schema and resource identities. +// Validate makes sure that each entry has a safe identity, a valid +// fingerprint, and a unique identity. func (manifest Manifest) Validate() error { seen := make(map[syncdomain.ResourceID]struct{}, len(manifest.Resources)) for _, resource := range manifest.Resources { - if err := validatePathSegment("resource kind", string(resource.ResourceKind)); err != nil { - return err - } - if err := validatePathSegment("project key", resource.ProjectKey); err != nil { - return err + if err := syncdomain.ValidateKey(string(resource.ResourceKind)); err != nil { + return fmt.Errorf("invalid resource kind %q: %w", resource.ResourceKind, err) } - if err := validateLookupKey(resource.LookupKey); err != nil { - return err - } - if !fingerprintPattern.MatchString(resource.Fingerprint) { - return fmt.Errorf("invalid fingerprint for %s/%s", resource.ProjectKey, resource.LookupKey) + if err := syncdomain.ValidateKey(resource.ProjectKey); err != nil { + return fmt.Errorf("invalid project key %q: %w", resource.ProjectKey, err) } - if resource.Version < 0 { - return fmt.Errorf("invalid version for %s/%s", resource.ProjectKey, resource.LookupKey) + for _, segment := range strings.Split(resource.LookupKey, "/") { + if err := syncdomain.ValidateKey(segment); err != nil { + return fmt.Errorf("invalid lookup key %q: %w", resource.LookupKey, err) + } } - identity := resource.ID() - if _, exists := seen[identity]; exists { - return fmt.Errorf("duplicate manifest resource %s/%s", resource.ProjectKey, resource.LookupKey) + id := resource.ID() + switch { + case !fingerprintPattern.MatchString(resource.Fingerprint): + return fmt.Errorf("invalid fingerprint for %s", id) + case resource.Version < 0: + return fmt.Errorf("invalid version for %s", id) } - seen[identity] = struct{}{} + if _, duplicate := seen[id]; duplicate { + return fmt.Errorf("duplicate manifest resource %s", id) + } + seen[id] = struct{}{} } return nil } -// Sort orders resources deterministically for stable Git diffs. +// Sort orders the entries by identity. func (manifest *Manifest) Sort() { slices.SortFunc(manifest.Resources, func(left, right Resource) int { - if result := strings.Compare(string(left.ResourceKind), string(right.ResourceKind)); result != 0 { - return result - } - if result := strings.Compare(left.ProjectKey, right.ProjectKey); result != 0 { - return result - } - return strings.Compare(left.LookupKey, right.LookupKey) + return syncdomain.CompareResourceIDs(left.ID(), right.ID()) }) } -// validateLookupKey checks every slash-delimited resource identity segment. -func validateLookupKey(value string) error { - for _, segment := range strings.Split(value, "/") { - if err := validatePathSegment("lookup segment", segment); err != nil { - return fmt.Errorf("invalid lookup key %q: %w", value, err) - } - } - return nil -} - -// validatePathSegment rejects values that are empty, unsafe, or non-portable. -func validatePathSegment(name, value string) error { - if value == "" || value == "." || value == ".." || strings.ContainsAny(value, `/\`) || strings.IndexByte(value, 0) >= 0 { - return fmt.Errorf("invalid %s %q", name, value) - } - return nil +func (manifest Manifest) index(id syncdomain.ResourceID) int { + return slices.IndexFunc(manifest.Resources, func(resource Resource) bool { return resource.ID() == id }) } diff --git a/internal/sync/manifest/model_test.go b/internal/sync/manifest/model_test.go index 3a3e04a2..4f92975f 100644 --- a/internal/sync/manifest/model_test.go +++ b/internal/sync/manifest/model_test.go @@ -67,11 +67,13 @@ func TestManifestRemovesOnlyUnreferencedAttachments(t *testing.T) { {ResourceKind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "search"}, {ResourceKind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "support"}, } - referenced := map[syncdomain.ResourceID]struct{}{ - {Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "search"}: {}, - } + search := syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Tool: &syncdomain.Tool{Key: "search"}} + variations := []syncdomain.SyncedResource{{ + ProjectKey: "project", + Variation: syncdomain.Variation{Attachments: []syncdomain.Attachment{search}}, + }} - manifest.RemoveUnreferencedAttachments(referenced) + manifest.RemoveUnusedAttachments(variations) assert.Equal(t, []Resource{ {ResourceKind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/default"}, diff --git a/internal/sync/manifest/store.go b/internal/sync/manifest/store.go index 3d62e5c7..23fccf08 100644 --- a/internal/sync/manifest/store.go +++ b/internal/sync/manifest/store.go @@ -4,11 +4,12 @@ import ( "errors" "fmt" "slices" - "strings" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" syncapi "github.com/launchdarkly/ldcli/internal/sync/api" ) +// The API accepts at most this number of upserts and deletions in one patch. const manifestBatchLimit = 100 type client interface { @@ -21,30 +22,27 @@ type client interface { ) (syncapi.SyncManifest, error) } -// Store persists synchronization baselines through LaunchDarkly. +// Store keeps the manifest in LaunchDarkly. LaunchDarkly stores one remote +// manifest for each project and repository source. type Store struct { client client source string } -// NewStore creates a remote manifest store for one repository source. +// NewStore creates a store for one repository source. func NewStore(client client, source string) Store { return Store{client: client, source: source} } -// Load combines the project-scoped remote manifests used by the workspace. +// Load reads the remote manifest of each project and combines them. func (store Store) Load(projectKeys []string) (Manifest, error) { - projectKeys = append([]string(nil), projectKeys...) - slices.Sort(projectKeys) - projectKeys = slices.Compact(projectKeys) - manifest := New() - for _, projectKey := range projectKeys { + for _, projectKey := range uniqueSorted(projectKeys) { remote, err := store.client.GetSyncManifest(projectKey, store.source) if err != nil { return Manifest{}, err } - resources, err := resourcesFromRemote(projectKey, store.source, remote) + resources, err := store.resourcesFromRemote(projectKey, remote) if err != nil { return Manifest{}, err } @@ -57,18 +55,16 @@ func (store Store) Load(projectKeys []string) (Manifest, error) { return manifest, nil } -// Update applies the difference between two aggregate manifest states. +// Update sends the difference between previous and next to LaunchDarkly. The +// previous manifest must come from Load, so that each entry has its remote +// version. Update returns the new remote state. func (store Store) Update(previous, next Manifest) (Manifest, error) { if err := previous.Validate(); err != nil { return Manifest{}, fmt.Errorf("validate previous sync manifest: %w", err) } for _, resource := range previous.Resources { if resource.Version == 0 { - return Manifest{}, fmt.Errorf( - "sync manifest resource %s/%s is missing its remote version", - resource.ProjectKey, - resource.LookupKey, - ) + return Manifest{}, fmt.Errorf("sync manifest resource %s is missing its remote version", resource.ID()) } } if err := next.Validate(); err != nil { @@ -76,81 +72,69 @@ func (store Store) Update(previous, next Manifest) (Manifest, error) { } result := New() - for _, projectKey := range manifestProjects(previous, next) { - before := projectResources(previous, projectKey) - after := projectResources(next, projectKey) - upserts, deletions := manifestChanges(before, after) - + for _, projectKey := range projectKeys(previous, next) { + before := previous.project(projectKey) + after := next.project(projectKey) + upserts, deletions := changes(before, after) if len(upserts) == 0 && len(deletions) == 0 { - result.Resources = append(result.Resources, retainVersions(before, after)...) + result.Resources = append(result.Resources, withVersions(after, before)...) continue } - remote, err := store.apply(projectKey, upserts, deletions) + remote, err := store.patch(projectKey, upserts, deletions) if err != nil { return Manifest{}, err } - resources, err := resourcesFromRemote(projectKey, store.source, remote) + resources, err := store.resourcesFromRemote(projectKey, remote) if err != nil { return Manifest{}, err } result.Resources = append(result.Resources, resources...) } - result.Sort() return result, nil } -func (store Store) apply( +// patch sends the changes in batches and returns the remote manifest after +// the last batch. If a batch fails without a response, patch reads the +// manifest to find whether LaunchDarkly applied it. +func (store Store) patch( projectKey string, upserts []syncapi.SyncManifestUpsert, deletions []syncapi.SyncManifestDeletion, ) (syncapi.SyncManifest, error) { var latest syncapi.SyncManifest for len(upserts) != 0 || len(deletions) != 0 { - upsertCount := min(len(upserts), manifestBatchLimit) - deletionCount := min(len(deletions), manifestBatchLimit) - batchUpserts := upserts[:upsertCount] - batchDeletions := deletions[:deletionCount] - - remote, err := store.client.PatchSyncManifest( - projectKey, - store.source, - batchUpserts, - batchDeletions, - ) - if err != nil { - if syncapi.IsConflict(err) { - return syncapi.SyncManifest{}, fmt.Errorf( - "sync manifest for project %q changed in LaunchDarkly; run sync again: %w", - projectKey, - err, - ) - } - if !syncapi.MutationMayHaveSucceeded(err) { - return syncapi.SyncManifest{}, err - } + batchUpserts := upserts[:min(len(upserts), manifestBatchLimit)] + batchDeletions := deletions[:min(len(deletions), manifestBatchLimit)] + upserts = upserts[len(batchUpserts):] + deletions = deletions[len(batchDeletions):] - remote, readErr := store.client.GetSyncManifest(projectKey, store.source) + remote, err := store.client.PatchSyncManifest(projectKey, store.source, batchUpserts, batchDeletions) + switch { + case err == nil: + case syncapi.IsConflict(err): + return syncapi.SyncManifest{}, fmt.Errorf( + "sync manifest for project %q changed in LaunchDarkly; run sync again: %w", projectKey, err, + ) + case !syncapi.MutationMayHaveSucceeded(err): + return syncapi.SyncManifest{}, err + default: + var readErr error + remote, readErr = store.client.GetSyncManifest(projectKey, store.source) if readErr != nil || !changesApplied(remote, batchUpserts, batchDeletions) { return syncapi.SyncManifest{}, errors.Join(err, readErr) } } - latest = remote - upserts = upserts[upsertCount:] - deletions = deletions[deletionCount:] } return latest, nil } -func resourcesFromRemote(projectKey, source string, remote syncapi.SyncManifest) ([]Resource, error) { - if remote.Source != "" && remote.Source != source { +func (store Store) resourcesFromRemote(projectKey string, remote syncapi.SyncManifest) ([]Resource, error) { + if remote.Source != "" && remote.Source != store.source { return nil, fmt.Errorf( - "sync manifest for project %q returned source %q instead of %q", - projectKey, - remote.Source, - source, + "sync manifest for project %q returned source %q instead of %q", projectKey, remote.Source, store.source, ) } @@ -158,10 +142,7 @@ func resourcesFromRemote(projectKey, source string, remote syncapi.SyncManifest) for _, item := range remote.Items { if item.Version < 1 { return nil, fmt.Errorf( - "sync manifest resource %s/%s returned invalid version %d", - projectKey, - item.ResourceLookupKey, - item.Version, + "sync manifest resource %s/%s returned invalid version %d", projectKey, item.ResourceLookupKey, item.Version, ) } resources = append(resources, Resource{ @@ -175,124 +156,113 @@ func resourcesFromRemote(projectKey, source string, remote syncapi.SyncManifest) return resources, nil } -func manifestProjects(manifests ...Manifest) []string { - var projectKeys []string - for _, manifest := range manifests { - for _, resource := range manifest.Resources { - projectKeys = append(projectKeys, resource.ProjectKey) - } - } - slices.Sort(projectKeys) - return slices.Compact(projectKeys) -} - -func projectResources(manifest Manifest, projectKey string) []Resource { - var resources []Resource - for _, resource := range manifest.Resources { - if resource.ProjectKey == projectKey { - resources = append(resources, resource) - } - } - return resources -} - -func manifestChanges( - previous []Resource, - next []Resource, -) ([]syncapi.SyncManifestUpsert, []syncapi.SyncManifestDeletion) { - before := make(map[identity]Resource, len(previous)) - for _, resource := range previous { - before[resourceIdentity(resource)] = resource +// changes compares two states of one project. The results are in identity order. +func changes(before, after []Resource) ([]syncapi.SyncManifestUpsert, []syncapi.SyncManifestDeletion) { + before, after = sorted(before), sorted(after) + remaining := make(map[syncdomain.ResourceID]Resource, len(before)) + for _, resource := range before { + remaining[resource.ID()] = resource } var upserts []syncapi.SyncManifestUpsert - remaining := make(map[identity]Resource, len(previous)) - for id, resource := range before { - remaining[id] = resource - } - - for _, resource := range next { - id := resourceIdentity(resource) - current, exists := before[id] - delete(remaining, id) + for _, resource := range after { + current, exists := remaining[resource.ID()] + delete(remaining, resource.ID()) if exists && current.Fingerprint == resource.Fingerprint { continue } - version := 0 - if exists { - version = current.Version - } upserts = append(upserts, syncapi.SyncManifestUpsert{ ResourceKind: resource.ResourceKind, ResourceLookupKey: resource.LookupKey, Fingerprint: resource.Fingerprint, - Version: version, + Version: current.Version, }) } var deletions []syncapi.SyncManifestDeletion - for _, resource := range remaining { - deletions = append(deletions, syncapi.SyncManifestDeletion{ - ResourceKind: resource.ResourceKind, - ResourceLookupKey: resource.LookupKey, - Version: resource.Version, - }) - } - slices.SortFunc(upserts, func(left, right syncapi.SyncManifestUpsert) int { - if left.ResourceKind != right.ResourceKind { - return strings.Compare(string(left.ResourceKind), string(right.ResourceKind)) - } - return strings.Compare(left.ResourceLookupKey, right.ResourceLookupKey) - }) - slices.SortFunc(deletions, func(left, right syncapi.SyncManifestDeletion) int { - if left.ResourceKind != right.ResourceKind { - return strings.Compare(string(left.ResourceKind), string(right.ResourceKind)) + for _, resource := range before { + if _, removed := remaining[resource.ID()]; removed { + deletions = append(deletions, syncapi.SyncManifestDeletion{ + ResourceKind: resource.ResourceKind, + ResourceLookupKey: resource.LookupKey, + Version: resource.Version, + }) } - return strings.Compare(left.ResourceLookupKey, right.ResourceLookupKey) - }) - return upserts, deletions -} - -type identity struct { - kind string - lookupKey string -} - -func resourceIdentity(resource Resource) identity { - return identity{kind: string(resource.ResourceKind), lookupKey: resource.LookupKey} -} - -func retainVersions(previous, next []Resource) []Resource { - versions := make(map[identity]int, len(previous)) - for _, resource := range previous { - versions[resourceIdentity(resource)] = resource.Version } - result := append([]Resource(nil), next...) - for index := range result { - result[index].Version = versions[resourceIdentity(result[index])] - } - return result + return upserts, deletions } +// changesApplied reports whether the remote manifest contains every change. func changesApplied( remote syncapi.SyncManifest, upserts []syncapi.SyncManifestUpsert, deletions []syncapi.SyncManifestDeletion, ) bool { - current := make(map[identity]syncapi.SyncManifestResource, len(remote.Items)) + type key struct { + kind syncdomain.Kind + lookupKey string + } + current := make(map[key]syncapi.SyncManifestResource, len(remote.Items)) for _, item := range remote.Items { - current[identity{kind: string(item.ResourceKind), lookupKey: item.ResourceLookupKey}] = item + current[key{item.ResourceKind, item.ResourceLookupKey}] = item } for _, upsert := range upserts { - item, ok := current[identity{kind: string(upsert.ResourceKind), lookupKey: upsert.ResourceLookupKey}] + item, ok := current[key{upsert.ResourceKind, upsert.ResourceLookupKey}] if !ok || item.Fingerprint != upsert.Fingerprint || item.Version <= upsert.Version { return false } } for _, deletion := range deletions { - if _, ok := current[identity{kind: string(deletion.ResourceKind), lookupKey: deletion.ResourceLookupKey}]; ok { + if _, ok := current[key{deletion.ResourceKind, deletion.ResourceLookupKey}]; ok { return false } } return true } + +// withVersions copies the remote version of each entry in before to the +// matching entry in after. +func withVersions(after, before []Resource) []Resource { + versions := make(map[syncdomain.ResourceID]int, len(before)) + for _, resource := range before { + versions[resource.ID()] = resource.Version + } + result := slices.Clone(after) + for index := range result { + result[index].Version = versions[result[index].ID()] + } + return result +} + +// project returns the entries of one project. +func (manifest Manifest) project(projectKey string) []Resource { + var resources []Resource + for _, resource := range manifest.Resources { + if resource.ProjectKey == projectKey { + resources = append(resources, resource) + } + } + return resources +} + +func sorted(resources []Resource) []Resource { + manifest := Manifest{Resources: slices.Clone(resources)} + manifest.Sort() + return manifest.Resources +} + +func projectKeys(manifests ...Manifest) []string { + var keys []string + for _, manifest := range manifests { + for _, resource := range manifest.Resources { + keys = append(keys, resource.ProjectKey) + } + } + return uniqueSorted(keys) +} + +func uniqueSorted(values []string) []string { + values = slices.Clone(values) + slices.Sort(values) + return slices.Compact(values) +} diff --git a/internal/sync/manifest/store_test.go b/internal/sync/manifest/store_test.go index 97d153dd..bc19553a 100644 --- a/internal/sync/manifest/store_test.go +++ b/internal/sync/manifest/store_test.go @@ -2,6 +2,7 @@ package manifest import ( "errors" + "net/url" "strings" "testing" @@ -123,6 +124,56 @@ func TestStoreReportsOptimisticConflict(t *testing.T) { require.ErrorContains(t, err, `sync manifest for project "project" changed in LaunchDarkly`) } +func TestStoreReadsBackAnUncertainPatchThatSucceeded(t *testing.T) { + applied := syncapi.SyncManifest{ + Source: "git:example/repo", + Items: []syncapi.SyncManifestResource{{ + ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "config/variation", + Fingerprint: fingerprint("b"), Version: 2, + }}, + } + client := &manifestClient{ + manifests: map[string]syncapi.SyncManifest{"project": applied}, + patchErr: uncertainPatchError(t), + } + previous := Manifest{Resources: []Resource{{ + ResourceKind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/variation", + Fingerprint: fingerprint("a"), Version: 1, + }}} + next := previous.Clone() + next.Resources[0].Fingerprint = fingerprint("b") + + updated, err := NewStore(client, "git:example/repo").Update(previous, next) + + require.NoError(t, err) + require.Equal(t, []Resource{{ + ResourceKind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/variation", + Fingerprint: fingerprint("b"), Version: 2, + }}, updated.Resources) +} + +// uncertainPatchError returns the error that the API client reports when a +// patch fails before LaunchDarkly sends a response. +func uncertainPatchError(t *testing.T) error { + t.Helper() + transport := &failingTransport{err: errors.New("connection reset")} + _, err := syncapi.NewClient(transport, "token", "https://example.com").PatchSyncManifest("project", "source", nil, nil) + require.True(t, syncapi.MutationMayHaveSucceeded(err)) + return err +} + +type failingTransport struct { + err error +} + +func (transport *failingTransport) MakeRequest(string, string, string, string, url.Values, []byte, bool) ([]byte, error) { + return nil, transport.err +} + +func (transport *failingTransport) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { + return nil, transport.err +} + func TestManifestValidation(t *testing.T) { valid := Resource{ ResourceKind: syncdomain.KindVariation, diff --git a/internal/sync/prompt/acceptance_test.go b/internal/sync/prompt/acceptance_test.go index bb8e9f02..6a3f923f 100644 --- a/internal/sync/prompt/acceptance_test.go +++ b/internal/sync/prompt/acceptance_test.go @@ -315,8 +315,8 @@ func TestPromptAttachesLatestToolToManagedVariation(t *testing.T) { resources, err := synclocal.CompileWorkspace(root) require.NoError(t, err) require.Len(t, resources, 1) - require.Len(t, resources[0].Attachments, 1) - assert.Equal(t, "search", resources[0].Attachments[0].Key()) + require.Len(t, resources[0].Variation.Attachments, 1) + assert.Equal(t, "search", resources[0].Variation.Attachments[0].Key()) _, err = os.Stat(filepath.Join(root, ".launchdarkly", "production", "tools", "search.json")) require.NoError(t, err) @@ -391,7 +391,7 @@ func TestPromptRejectsSkillAttachmentForCompletionVariation(t *testing.T) { resources, compileErr := synclocal.CompileWorkspace(root) require.NoError(t, compileErr) require.Len(t, resources, 1) - assert.Empty(t, resources[0].Attachments) + assert.Empty(t, resources[0].Variation.Attachments) } func TestPromptCreatesMissingToolWhenUpsertIsEnabled(t *testing.T) { @@ -597,8 +597,7 @@ func TestPromptPullsServerChangeAndAdvancesManifest(t *testing.T) { resources, err := synclocal.Compile(os.DirFS(root)) require.NoError(t, err) require.Len(t, resources, 1) - var actual syncdomain.Variation - require.NoError(t, json.Unmarshal(resources[0].Payload, &actual)) + actual := resources[0].Variation assert.Equal(t, server.Name, actual.Name) assertManifestFingerprint(t, root, server) } @@ -618,8 +617,7 @@ func TestPromptRoundTripsOutputFormat(t *testing.T) { resources, err := synclocal.Compile(os.DirFS(root)) require.NoError(t, err) require.Len(t, resources, 1) - var pulled syncdomain.Variation - require.NoError(t, json.Unmarshal(resources[0].Payload, &pulled)) + pulled := resources[0].Variation assert.Equal(t, server.OutputFormat, pulled.OutputFormat) local := server @@ -1210,8 +1208,7 @@ func requireLocalModelConfigVersion(t *testing.T, root string, expected int) { require.NoError(t, err) require.Len(t, resources, 1) - var persisted syncdomain.Variation - require.NoError(t, json.Unmarshal(resources[0].Payload, &persisted)) + persisted := resources[0].Variation require.Equal(t, expected, persisted.ModelConfigVersion) } diff --git a/internal/sync/prompt/action.go b/internal/sync/prompt/action.go new file mode 100644 index 00000000..201de515 --- /dev/null +++ b/internal/sync/prompt/action.go @@ -0,0 +1,135 @@ +package prompt + +import ( + "context" + "errors" + "io" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + synclink "github.com/launchdarkly/ldcli/internal/sync/link" + syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" +) + +// CommandAction is one prompt sync operation. +type CommandAction interface { + validate() error +} + +// SyncAction reconciles the workspace once, or each time a watched file changes. +type SyncAction struct { + Watch bool + DryRun bool +} + +// AddAction writes selected LaunchDarkly variations as local files. +type AddAction struct { + Variations []syncdomain.ResourceID + DryRun bool +} + +// AttachAction adds a tool or a skill to a local variation, and then syncs. +type AttachAction struct { + Kind syncdomain.AttachmentKind + Key string + Target *syncdomain.ResourceID +} + +// DetachAction stops syncing selected local variations. +type DetachAction struct { + Variations []syncdomain.ResourceID +} + +// LinkAction creates a variation whose prompt is an external file, and then syncs. +type LinkAction struct { + File string + Format string + Target *synclink.Target +} + +func (action SyncAction) validate() error { + if action.Watch && action.DryRun { + return errors.New("watch does not support --dry-run") + } + return nil +} + +func (AddAction) validate() error { return nil } +func (DetachAction) validate() error { return nil } + +func (action AttachAction) validate() error { + if !action.Kind.Valid() { + return errors.New("attachment kind must be tool or skill") + } + if action.Target != nil && action.Target.Kind != syncdomain.KindVariation { + return errors.New("attachment target must be a variation") + } + return nil +} + +func (action LinkAction) validate() error { + switch { + case action.File == "": + return errors.New("linked file is required") + case action.Format == "": + return errors.New("--format is required") + case action.Target != nil && action.Target.ModelConfigKey == "": + return errors.New("--model-config-key is required with --to") + } + return syncreference.ValidateFormat(action.Format) +} + +// Options are the command input and the streams of one prompt sync command. +type Options struct { + WorkingDirectory string + AccessToken string + BaseURI string + OutputKind string + // Action is the operation to run. A nil action is a SyncAction. + Action CommandAction + ConflictPolicy ConflictPolicy + // Yes applies the plan without a confirmation prompt. + Yes bool + // NoInput makes each prompt for missing input an error. + NoInput bool + Context context.Context + Input io.Reader + Output io.Writer + ErrorOutput io.Writer +} + +// validateOptions rejects an action whose flags conflict. +func validateOptions(options Options) error { + if options.Action == nil { + return nil + } + return options.Action.validate() +} + +// withDefaults returns the options with a context and an action. +func (options Options) withDefaults() Options { + if options.Context == nil { + options.Context = context.Background() + } + if options.Action == nil { + options.Action = SyncAction{} + } + return options +} + +// dryRun reports whether the action previews changes without writing them. +func (options Options) dryRun() bool { + switch action := options.Action.(type) { + case SyncAction: + return action.DryRun + case AddAction: + return action.DryRun + default: + return false + } +} + +// watching reports whether the action is a watch. +func (options Options) watching() bool { + action, ok := options.Action.(SyncAction) + return ok && action.Watch +} diff --git a/internal/sync/prompt/attach.go b/internal/sync/prompt/attach.go index 5977f419..54174ccb 100644 --- a/internal/sync/prompt/attach.go +++ b/internal/sync/prompt/attach.go @@ -1,7 +1,6 @@ package prompt import ( - "encoding/json" "errors" "fmt" "io" @@ -17,29 +16,32 @@ const attachmentSearchPageSize = 25 var errAttachmentCanceled = errors.New("attachment selection canceled") +// attachOptions are the input of one attach. An empty field means that the +// user did not give the value. In interactive mode, the user then chooses it. type attachOptions struct { RepositoryRoot string ProjectKey string - VariationID string - Kind syncdomain.AttachmentKind - Key string - Interactive bool - Input io.Reader - Output io.Writer + // VariationID is the lookup key of the variation, "config-key/variation-key". + VariationID string + Kind syncdomain.AttachmentKind + Key string + Interactive bool + Input io.Reader + Output io.Writer } -// attachToVariation materializes the selected latest dependency and adds its -// key to one existing local variation. The normal sync pipeline applies it. +// attachToVariation adds a tool or a skill to one local variation, and writes +// the file of the attachment. The next sync applies the change. func attachToVariation(store synclocal.Store, client syncapi.Client, options attachOptions) error { - resources, err := synclocal.CompileWorkspace(options.RepositoryRoot) + variations, err := synclocal.CompileWorkspace(options.RepositoryRoot) if err != nil { return err } - projectKey, err := selectAttachmentProject(resources, options) + projectKey, err := selectAttachmentProject(variations, options) if err != nil { return err } - variationID, variation, err := selectManagedVariation(resources, projectKey, options) + target, err := selectManagedVariation(variations, projectKey, options) if err != nil { return err } @@ -47,68 +49,63 @@ func attachToVariation(store synclocal.Store, client syncapi.Client, options att if err != nil { return err } - // Attachment files are shared by every local consumer. Reuse existing - // canonical content so attaching another variation never overwrites edits - // before the normal sync conflict flow can review them. - if local, ok := localAttachment(resources, projectKey, attachment.Kind, attachment.Key()); ok { + + // Other local variations can share the attachment file. Keep its local + // content, so that attach never overwrites a local edit. The next sync + // shows a conflict if the content differs from LaunchDarkly. + if local, ok := localAttachment(variations, attachment.ID(projectKey)); ok { attachment = local } - - addAttachmentContent(&variation, attachment) - if err := variation.NormalizeAttachments(); err != nil { + variation := target.Variation + variation.Attach(attachment) + if err := variation.Validate(); err != nil { return err } - configKey, _, err := splitVariationLookupKey(variationID) + + configKey, _, err := target.ID().VariationKeys() if err != nil { return err } - return store.AttachVariation(projectKey, configKey, variation) + _, err = store.ReplaceVariations([]synclocal.VariationReplacement{{ + ProjectKey: projectKey, ConfigKey: configKey, Variation: variation, + }}) + return err } -// localAttachment finds canonical content already shared by managed variations. -func localAttachment( - resources []syncdomain.SyncedResource, - projectKey string, - kind syncdomain.AttachmentKind, - key string, -) (syncdomain.Attachment, bool) { - for _, resource := range resources { - if resource.ProjectKey != projectKey { +// localAttachment finds the local content of an attachment. +func localAttachment(variations []syncdomain.SyncedResource, id ResourceID) (syncdomain.Attachment, bool) { + for _, variation := range variations { + if variation.ProjectKey != id.ProjectKey { continue } - for _, attachment := range resource.Attachments { - if attachment.Kind == kind && attachment.Key() == key { - return attachment, true - } + if attachment, ok := variation.Variation.Attachment(syncdomain.AttachmentKind(id.Kind), id.LookupKey); ok { + return attachment, true } } return syncdomain.Attachment{}, false } -// selectAttachmentProject resolves an explicit project or guides the user -// through locally managed projects. -func selectAttachmentProject(resources []syncdomain.SyncedResource, options attachOptions) (string, error) { +// selectAttachmentProject returns the project of the attachment. A workspace +// with one project uses it. Otherwise the user names or chooses the project. +func selectAttachmentProject(variations []syncdomain.SyncedResource, options attachOptions) (string, error) { var projectKeys []string - for _, resource := range resources { - if resource.Kind == syncdomain.KindVariation && !slices.Contains(projectKeys, resource.ProjectKey) { - projectKeys = append(projectKeys, resource.ProjectKey) - } + for _, variation := range variations { + projectKeys = append(projectKeys, variation.ProjectKey) } slices.Sort(projectKeys) - if options.ProjectKey != "" { - if slices.Contains(projectKeys, options.ProjectKey) { - return options.ProjectKey, nil - } + projectKeys = slices.Compact(projectKeys) + + switch { + case options.ProjectKey != "" && slices.Contains(projectKeys, options.ProjectKey): + return options.ProjectKey, nil + case options.ProjectKey != "": return "", fmt.Errorf("project %q is not managed by this workspace", options.ProjectKey) - } - switch len(projectKeys) { - case 0: - return "", fmt.Errorf("no synchronized projects are available") - case 1: + case len(projectKeys) == 0: + return "", errors.New("no synchronized projects are available") + case len(projectKeys) == 1: return projectKeys[0], nil - } - if !options.Interactive { - return "", fmt.Errorf("--to is required when the workspace manages multiple projects") + case !options.Interactive: + return "", errors.New("--to is required when the workspace manages multiple projects") } choices := make([]syncinteractive.Choice[string], 0, len(projectKeys)) @@ -122,7 +119,54 @@ func selectAttachmentProject(resources []syncdomain.SyncedResource, options atta return selected, err } -// selectAttachment resolves a direct key or searches the server catalog. +// selectManagedVariation returns the local variation that gets the +// attachment. A skill needs an agent variation. +func selectManagedVariation( + variations []syncdomain.SyncedResource, + projectKey string, + options attachOptions, +) (syncdomain.SyncedResource, error) { + var choices []syncinteractive.Choice[syncdomain.SyncedResource] + for _, variation := range variations { + if variation.ProjectKey != projectKey { + continue + } + if options.Kind == syncdomain.AttachmentSkill && variation.Variation.Mode != syncdomain.VariationModeAgent { + if options.VariationID == variation.LookupKey { + return syncdomain.SyncedResource{}, errors.New("skills can only be attached to agent-mode configs") + } + continue + } + choices = append(choices, syncinteractive.Choice[syncdomain.SyncedResource]{ + Title: variation.Variation.Name, Description: "Key: " + variation.LookupKey, Value: variation, + }) + } + + if options.VariationID != "" { + for _, choice := range choices { + if choice.Value.LookupKey == options.VariationID { + return choice.Value, nil + } + } + return syncdomain.SyncedResource{}, fmt.Errorf("variation %q is not managed in project %q", options.VariationID, projectKey) + } + if !options.Interactive { + return syncdomain.SyncedResource{}, errors.New("--to is required without interactive input") + } + if len(choices) == 0 { + return syncdomain.SyncedResource{}, fmt.Errorf("no eligible variations are managed in project %q", projectKey) + } + + title := fmt.Sprintf("Choose a synced variation to attach the %s to", options.Kind) + selected, canceled, err := syncinteractive.Select(options.Input, options.Output, title, choices) + if canceled { + return syncdomain.SyncedResource{}, errAttachmentCanceled + } + return selected, err +} + +// selectAttachment reads the attachment that the user named, or asks the user +// to search for one. It returns the latest version. func selectAttachment(client syncapi.Client, projectKey string, options attachOptions) (syncdomain.Attachment, error) { if options.Key != "" { return client.ReadAttachment(projectKey, options.Kind, options.Key) @@ -131,13 +175,14 @@ func selectAttachment(client syncapi.Client, projectKey string, options attachOp return syncdomain.Attachment{}, fmt.Errorf("%s key is required without interactive input", options.Kind) } + kind := string(options.Kind) attachment, canceled, err := syncinteractive.SearchSelect(syncinteractive.SearchOptions[syncdomain.Attachment]{ Input: options.Input, Output: options.Output, - SearchTitle: "Search " + string(options.Kind) + "s", + SearchTitle: "Search " + kind + "s", SearchPlaceholder: "Name or key", - SelectTitle: "Choose a " + string(options.Kind), - ItemName: string(options.Kind) + "s", + SelectTitle: "Choose a " + kind, + ItemName: kind + "s", PageSize: attachmentSearchPageSize, Fetch: func(query string, limit, offset int) ([]syncdomain.Attachment, int, error) { return searchAttachmentPage(client, projectKey, options.Kind, query, limit, offset) @@ -158,98 +203,24 @@ func searchAttachmentPage( projectKey string, kind syncdomain.AttachmentKind, query string, - limit int, - offset int, + limit, offset int, ) ([]syncdomain.Attachment, int, error) { page, err := client.SearchAttachments(projectKey, kind, query, limit, offset) - if err != nil { - return nil, 0, err - } - return page.Items, page.TotalCount, nil + return page.Items, page.TotalCount, err } +// attachmentChoice shows the name of a skill, or the key of a tool, which has no name. func attachmentChoice(attachment syncdomain.Attachment) syncinteractive.Choice[syncdomain.Attachment] { - title := "Key: " + attachment.Key() - description := fmt.Sprintf("Latest version: %d", attachment.Version) if attachment.Skill != nil { - title = attachment.Skill.Name - description = fmt.Sprintf("Key: %s · Latest version: %d", attachment.Key(), attachment.Version) - } - return syncinteractive.Choice[syncdomain.Attachment]{Title: title, Description: description, Value: attachment} -} - -// selectManagedVariation resolves an explicit consumer or prompts from -// variations already represented in the local workspace. -func selectManagedVariation( - resources []syncdomain.SyncedResource, - projectKey string, - options attachOptions, -) (string, syncdomain.Variation, error) { - choices := make([]syncinteractive.Choice[managedVariation], 0) - for _, resource := range resources { - if resource.Kind != syncdomain.KindVariation || resource.ProjectKey != projectKey { - continue - } - var variation syncdomain.Variation - if err := json.Unmarshal(resource.Payload, &variation); err != nil { - return "", syncdomain.Variation{}, err - } - variation.Attachments = resource.Attachments - if options.Kind == syncdomain.AttachmentSkill && variation.Mode != syncdomain.VariationModeAgent { - if options.VariationID == resource.LookupKey { - return "", syncdomain.Variation{}, fmt.Errorf("skills can only be attached to agent-mode configs") - } - continue - } - choices = append(choices, syncinteractive.Choice[managedVariation]{ - Title: variation.Name, Description: "Key: " + resource.LookupKey, - Value: managedVariation{id: resource.LookupKey, variation: variation}, - }) - } - - if options.VariationID != "" { - for _, choice := range choices { - if choice.Value.id == options.VariationID { - return choice.Value.id, choice.Value.variation, nil - } + return syncinteractive.Choice[syncdomain.Attachment]{ + Title: attachment.Skill.Name, + Description: fmt.Sprintf("Key: %s · Latest version: %d", attachment.Key(), attachment.Version), + Value: attachment, } - return "", syncdomain.Variation{}, fmt.Errorf("variation %q is not managed in project %q", options.VariationID, projectKey) - } - if !options.Interactive { - return "", syncdomain.Variation{}, fmt.Errorf("--to is required without interactive input") } - if len(choices) == 0 { - return "", syncdomain.Variation{}, fmt.Errorf("no eligible variations are managed in project %q", projectKey) - } - - title := fmt.Sprintf("Choose a synced variation to attach the %s to", options.Kind) - selected, canceled, err := syncinteractive.Select(options.Input, options.Output, title, choices) - if canceled { - return "", syncdomain.Variation{}, errAttachmentCanceled - } - return selected.id, selected.variation, err -} - -type managedVariation struct { - id string - variation syncdomain.Variation -} - -// addAttachmentContent adds one key and its canonical dependency content. -func addAttachmentContent(variation *syncdomain.Variation, attachment syncdomain.Attachment) { - switch attachment.Kind { - case syncdomain.AttachmentTool: - addAttachmentRefByKind(&variation.Tools, syncdomain.AttachmentRef{Key: attachment.Key()}) - case syncdomain.AttachmentSkill: - addAttachmentRefByKind(&variation.Skills, syncdomain.AttachmentRef{Key: attachment.Key()}) - } - variation.SetAttachment(attachment) -} - -// addAttachmentRefByKind inserts a key once while preserving current order. -func addAttachmentRefByKind(refs *[]syncdomain.AttachmentRef, ref syncdomain.AttachmentRef) { - if slices.ContainsFunc(*refs, func(existing syncdomain.AttachmentRef) bool { return existing.Key == ref.Key }) { - return + return syncinteractive.Choice[syncdomain.Attachment]{ + Title: "Key: " + attachment.Key(), + Description: fmt.Sprintf("Latest version: %d", attachment.Version), + Value: attachment, } - *refs = append(*refs, ref) } diff --git a/internal/sync/prompt/attachment_versions.go b/internal/sync/prompt/attachment_versions.go new file mode 100644 index 00000000..dd66981d --- /dev/null +++ b/internal/sync/prompt/attachment_versions.go @@ -0,0 +1,95 @@ +package prompt + +import ( + "errors" + "fmt" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" +) + +// attachmentResolver publishes the local content of each tool and skill, and +// returns the LaunchDarkly version that has that content. A variation must +// pin that version when sync writes it. +// +// Many variations can share an attachment. The resolver keeps each result and +// each failure, so that it writes an attachment at most once in one execution. +type attachmentResolver struct { + client syncapi.Client + resolved map[ResourceID]syncdomain.Attachment + failures map[ResourceID]error +} + +func newAttachmentResolver(client syncapi.Client) *attachmentResolver { + return &attachmentResolver{ + client: client, + resolved: make(map[ResourceID]syncdomain.Attachment), + failures: make(map[ResourceID]error), + } +} + +// resolveVariation returns a copy of the variation that pins the published +// version of each attachment. +func (resolver *attachmentResolver) resolveVariation(projectKey string, variation syncdomain.Variation) (syncdomain.Variation, error) { + return variation.PinAttachments(func(local syncdomain.Attachment) (int, error) { + published, err := resolver.resolve(projectKey, local) + return published.Version, err + }) +} + +// resolve returns the LaunchDarkly attachment that has the local content. If +// the content differs, resolve writes it and reads the new version. +func (resolver *attachmentResolver) resolve(projectKey string, local syncdomain.Attachment) (syncdomain.Attachment, error) { + id := local.ID(projectKey) + if err, failed := resolver.failures[id]; failed { + return syncdomain.Attachment{}, err + } + if attachment, ok := resolver.resolved[id]; ok { + return attachment, nil + } + + attachment, err := resolver.publish(projectKey, local) + if err != nil { + resolver.failures[id] = err + return syncdomain.Attachment{}, err + } + resolver.resolved[id] = attachment + return attachment, nil +} + +func (resolver *attachmentResolver) publish(projectKey string, local syncdomain.Attachment) (syncdomain.Attachment, error) { + remote, err := resolver.client.ReadAttachment(projectKey, local.Kind, local.Key()) + var writeErr error + switch { + case err == nil && syncdomain.SameAttachmentContent(local, remote): + return remote, nil + case err == nil: + writeErr = resolver.client.UpdateAttachment(projectKey, local) + case syncapi.IsNotFound(err) && local.Kind == syncdomain.AttachmentTool && local.Upsert: + writeErr = resolver.client.CreateAttachment(projectKey, local) + case syncapi.IsNotFound(err) && local.Kind == syncdomain.AttachmentTool: + return syncdomain.Attachment{}, fmt.Errorf( + "tool %q does not exist in LaunchDarkly; add \"upsert\": true to its local JSON file to create it during sync", + local.Key(), + ) + default: + return syncdomain.Attachment{}, err + } + if writeErr != nil && !syncapi.MutationMayHaveSucceeded(writeErr) { + return syncdomain.Attachment{}, writeErr + } + + // LaunchDarkly assigns the version, so read the attachment again. The read + // also finds the result of a write whose response was lost. + observed, err := resolver.client.ReadAttachment(projectKey, local.Kind, local.Key()) + if err != nil { + return syncdomain.Attachment{}, errors.Join(writeErr, err) + } + if !syncdomain.SameAttachmentContent(local, observed) { + if writeErr == nil { + writeErr = fmt.Errorf("%s %q changed concurrently", local.Kind, local.Key()) + } + return syncdomain.Attachment{}, writeErr + } + return observed, nil +} diff --git a/internal/sync/prompt/conflict.go b/internal/sync/prompt/conflict.go index dc6f4d32..3abd0ac5 100644 --- a/internal/sync/prompt/conflict.go +++ b/internal/sync/prompt/conflict.go @@ -2,44 +2,83 @@ package prompt import ( "context" - "crypto/sha256" "errors" "fmt" "io" "slices" - "time" syncconsole "github.com/launchdarkly/ldcli/internal/sync/console" syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive" ) -// ConflictResolution identifies which side should win a sync conflict. +// ConflictResolution is the side that wins a conflict. type ConflictResolution string const ( - // ConflictUseLaunchDarkly applies the current LaunchDarkly resource locally. + // ConflictUseLaunchDarkly writes the LaunchDarkly state to the local file. ConflictUseLaunchDarkly ConflictResolution = "launchdarkly" - // ConflictUseLocal applies the current local resource to LaunchDarkly. + // ConflictUseLocal writes the local state to LaunchDarkly. ConflictUseLocal ConflictResolution = "local" - // ConflictAbort stops before applying the conflicted plan. + // ConflictAbort stops the sync before it writes anything. ConflictAbort ConflictResolution = "abort" ) -// ConflictPolicy supplies a default resolution and per-variation exceptions. +// Valid reports whether the resolution is one of the supported choices. +func (resolution ConflictResolution) Valid() bool { + return resolution == ConflictUseLaunchDarkly || resolution == ConflictUseLocal || resolution == ConflictAbort +} + +// ParseConflictResolution parses a conflict choice from the command line. +func ParseConflictResolution(value string) (ConflictResolution, error) { + if resolution := ConflictResolution(value); resolution.Valid() { + return resolution, nil + } + return "", fmt.Errorf("invalid conflict resolution %q; expected launchdarkly, local, or abort", value) +} + +// ConflictPolicy is a default choice and a choice for each named variation. +// A command without a policy asks the user. type ConflictPolicy struct { Default ConflictResolution Overrides map[ResourceID]ConflictResolution } -// ParseConflictResolution validates a CLI conflict choice. -func ParseConflictResolution(value string) (ConflictResolution, error) { - resolution := ConflictResolution(value) - switch resolution { - case ConflictUseLaunchDarkly, ConflictUseLocal, ConflictAbort: - return resolution, nil - default: - return "", fmt.Errorf("invalid conflict resolution %q; expected launchdarkly, local, or abort", value) +// resolve returns the policy choice for a group. The bool result is false when +// the policy has no choice for the group. +func (policy ConflictPolicy) resolve(group conflictGroup) (ConflictResolution, bool, error) { + var selected ConflictResolution + for _, resource := range group.resources { + resolution, ok := policy.Overrides[resource.ID] + if !ok { + continue + } + if !resolution.Valid() { + return "", false, fmt.Errorf("invalid conflict resolution %q", resolution) + } + if selected != "" && selected != resolution { + return "", false, errors.New("conflicting --resolve choices affect the same shared attachment") + } + selected = resolution + } + if selected == "" { + selected = policy.Default + } + if selected == "" { + return "", false, nil + } + if !selected.Valid() { + return "", false, fmt.Errorf("invalid conflict resolution %q", selected) } + return selected, true, nil +} + +var errConflictAborted = errors.New("sync conflict left unresolved") + +// conflictGroup is one conflict choice. Variations that share a changed +// attachment are in one group, because the attachment can have only one state. +type conflictGroup struct { + resources []PlannedResource + attachment bool } type conflictResolutionResult struct { @@ -48,20 +87,14 @@ type conflictResolutionResult struct { sourcesChanged bool } -var errConflictAborted = errors.New("sync conflict left unresolved") - type conflictChoice struct { resolution ConflictResolution aborted bool sourcesChanged bool } -type conflictGroup struct { - resources []PlannedResource - attachment bool -} - -// resolveConflicts shows each conflict and asks which side should win. +// resolveConflicts shows each conflict and gets a choice from the policy or +// from the user. In watch mode, a file change during the choice stops it. func resolveConflicts( options Options, plan Plan, @@ -69,65 +102,44 @@ func resolveConflicts( interactive bool, watched *watchedSources, ) (conflictResolutionResult, error) { - conflicts := groupConflicts(plan) - if len(conflicts) == 0 { + groups := groupConflicts(plan) + if len(groups) == 0 { return conflictResolutionResult{}, nil } result := conflictResolutionResult{resolutions: make(map[ResourceID]ConflictResolution)} - for _, group := range conflicts { - visible := make([]PlannedResource, 0, len(group.resources)) - for _, resource := range group.resources { - if len(resource.Diff) != 0 { - visible = append(visible, resource) - } - } - if len(visible) == 0 { - visible = append(visible, group.resources[0]) - } - conflict := Plan{Resources: visible} - if err := writePlanReview(options.ErrorOutput, "plaintext", conflict, terminalWidth(options.ErrorOutput)); err != nil { + for _, group := range groups { + if err := writeConflict(options.ErrorOutput, group); err != nil { return conflictResolutionResult{}, err } - if group.attachment && len(group.resources) > 1 { - console := syncconsole.New(options.ErrorOutput) - _ = console.Printf("This attachment conflict affects %d variations:\n", len(group.resources)) - for _, resource := range group.resources { - _ = console.Printf("- %s/%s\n", resource.ID.ProjectKey, resource.ID.LookupKey) - } - } - resolution, explicit, err := options.ConflictPolicy.resolve(group) + resolution, fromPolicy, err := options.ConflictPolicy.resolve(group) if err != nil { return conflictResolutionResult{}, err } var choice conflictChoice - if explicit { - choice = conflictChoice{ - resolution: resolution, - aborted: resolution == ConflictAbort, - } + switch { + case fromPolicy: + choice = conflictChoice{resolution: resolution, aborted: resolution == ConflictAbort} writeConflictChoice(options.ErrorOutput, choice) if choice.aborted { return conflictResolutionResult{}, errConflictAborted } - } else { - if !interactive { - return conflictResolutionResult{}, fmt.Errorf( - "conflict resolution requires --conflict or --resolve without interactive input", - ) - } - choice, err = readConflictChoice(options, reader, watched) - if err != nil { - return conflictResolutionResult{}, err - } + case !interactive: + return conflictResolutionResult{}, errors.New( + "conflict resolution requires --conflict or --resolve without interactive input", + ) + case watched != nil: + choice, err = promptWatchedConflictResolution(options.Context, options.Input, options.ErrorOutput, *watched) + default: + choice, err = promptConflictResolution(options.Context, reader, options.ErrorOutput) } - if choice.sourcesChanged { - result.sourcesChanged = true - return result, nil + if err != nil { + return conflictResolutionResult{}, err } - if choice.aborted { - result.aborted = true + + if choice.sourcesChanged || choice.aborted { + result.sourcesChanged, result.aborted = choice.sourcesChanged, choice.aborted return result, nil } for _, resource := range group.resources { @@ -137,56 +149,53 @@ func resolveConflicts( return result, nil } -func (policy ConflictPolicy) resolve(group conflictGroup) (ConflictResolution, bool, error) { - var selected ConflictResolution - for _, resource := range group.resources { - resolution, ok := policy.Overrides[resource.ID] - if !ok { - continue - } - if _, err := ParseConflictResolution(string(resolution)); err != nil { - return "", false, err - } - if selected != "" && selected != resolution { - return "", false, fmt.Errorf("conflicting --resolve choices affect the same shared attachment") - } - selected = resolution +// writeConflict shows the diff of each resource in a group that has one. +func writeConflict(output io.Writer, group conflictGroup) error { + visible := slices.DeleteFunc(slices.Clone(group.resources), func(resource PlannedResource) bool { + return len(resource.Diff) == 0 + }) + if len(visible) == 0 { + visible = group.resources[:1] } - if selected != "" { - return selected, true, nil + if err := writePlanReview(output, "plaintext", Plan{Resources: visible}, terminalWidth(output)); err != nil { + return err } - if policy.Default == "" { - return "", false, nil + if group.attachment && len(group.resources) > 1 { + console := syncconsole.New(output) + _ = console.Printf("This attachment conflict affects %d variations:\n", len(group.resources)) + for _, resource := range group.resources { + _ = console.Printf("- %s\n", resource.ID) + } } - resolution, err := ParseConflictResolution(string(policy.Default)) - return resolution, err == nil, err + return nil } -// groupConflicts presents one choice for each connected set of variations that -// share a changed dependency, including conflicts with other local edits. +// groupConflicts returns one group for each conflict. A group also has every +// other variation that shares a changed attachment with it, directly or +// through another variation in the group. func groupConflicts(plan Plan) []conflictGroup { var groups []conflictGroup visited := make([]bool, len(plan.Resources)) - for start := range plan.Resources { - if visited[start] || plan.Resources[start].Action != ActionConflict { + for start, resource := range plan.Resources { + if visited[start] || resource.Action != ActionConflict { continue } visited[start] = true - indices := []int{start} - for next := 0; next < len(indices); next++ { + members := []int{start} + for next := 0; next < len(members); next++ { for candidate := range plan.Resources { if visited[candidate] || plan.Resources[candidate].Action == ActionError || - !sharesChangedAttachment(plan.Resources[indices[next]], plan.Resources[candidate]) { + !sharesChangedAttachment(plan.Resources[members[next]], plan.Resources[candidate]) { continue } visited[candidate] = true - indices = append(indices, candidate) + members = append(members, candidate) } } - group := conflictGroup{attachment: len(indices) > 1} - for _, index := range indices { + group := conflictGroup{attachment: len(members) > 1} + for _, index := range members { group.resources = append(group.resources, plan.Resources[index]) } groups = append(groups, group) @@ -194,27 +203,15 @@ func groupConflicts(plan Plan) []conflictGroup { return groups } -// sharesChangedAttachment identifies variations that must resolve their shared -// dependency in the same direction. +// sharesChangedAttachment reports whether two variations share a changed +// attachment, so that both must use the same side. func sharesChangedAttachment(left, right PlannedResource) bool { - for _, leftID := range left.changedAttachments { - if slices.Contains(right.changedAttachments, leftID) { - return true - } - } - return false -} - -// readConflictChoice waits for a regular terminal choice or a watch-aware -// choice that can be interrupted by another source change. -func readConflictChoice(options Options, reader io.Reader, watched *watchedSources) (conflictChoice, error) { - if watched != nil { - return promptWatchedConflictResolution(options.Context, options.Input, options.ErrorOutput, *watched) - } - return promptConflictResolution(context.Background(), reader, options.ErrorOutput) + return slices.ContainsFunc(left.changedAttachments, func(id ResourceID) bool { + return slices.Contains(right.changedAttachments, id) + }) } -// promptConflictResolution asks which side should win one conflict. +// promptConflictResolution asks the user which side wins one conflict. func promptConflictResolution(ctx context.Context, input io.Reader, output io.Writer) (conflictChoice, error) { resolution, canceled, err := syncinteractive.SelectContext( ctx, @@ -230,16 +227,13 @@ func promptConflictResolution(ctx context.Context, input io.Reader, output io.Wr if err != nil { return conflictChoice{}, err } - choice := conflictChoice{ - resolution: resolution, - aborted: canceled || resolution == ConflictAbort, - } + choice := conflictChoice{resolution: resolution, aborted: canceled || resolution == ConflictAbort} writeConflictChoice(output, choice) return choice, nil } -// promptWatchedConflictResolution refreshes the plan if a source changes -// while the conflict selector is open. +// promptWatchedConflictResolution asks the user which side wins, and stops +// when a watched file changes first. The plan is then out of date. func promptWatchedConflictResolution( ctx context.Context, input io.Reader, @@ -249,37 +243,35 @@ func promptWatchedConflictResolution( promptContext, cancel := context.WithCancel(ctx) defer cancel() + // The prompt and the watcher race. The first to finish cancels the + // shared context, so that the other stops. changeResult := make(chan error, 1) - // Race the form against filesystem changes. Canceling the shared child - // context guarantees exactly one path wins and the other exits promptly. go func() { changeResult <- watched.WaitForChange(promptContext) cancel() }() - choice, promptErr := promptConflictResolution(promptContext, input, output) cancel() changeErr := <-changeResult - if changeErr == nil { + + switch { + case changeErr == nil: _ = syncconsole.New(output).Line("\nA watched file changed; refreshing...") return conflictChoice{sourcesChanged: true}, nil - } - if !errors.Is(changeErr, context.Canceled) { + case !errors.Is(changeErr, context.Canceled): return conflictChoice{}, changeErr - } - if promptErr == nil && choice.aborted { + case promptErr == nil && choice.aborted: return conflictChoice{}, context.Canceled - } - if promptErr != nil { - if ctx.Err() != nil { - return conflictChoice{}, context.Canceled - } + case promptErr != nil && ctx.Err() != nil: + return conflictChoice{}, context.Canceled + case promptErr != nil: return conflictChoice{}, promptErr + default: + return choice, nil } - return choice, nil } -// writeConflictChoice confirms the selected resolution after the form exits. +// writeConflictChoice confirms the choice after the form closes. func writeConflictChoice(output io.Writer, choice conflictChoice) { console := syncconsole.New(output) switch { @@ -292,47 +284,21 @@ func writeConflictChoice(output io.Writer, choice conflictChoice) { } } -type watchedSources struct { - watcher *sourceWatcher - snapshot [sha256.Size]byte - debounce time.Duration -} - -// WaitForChange waits until the watched source content differs from the state -// used to build the current plan. -func (watched watchedSources) WaitForChange(ctx context.Context) error { - for { - if err := watched.watcher.WaitForChange(ctx, watched.debounce); err != nil { - return err - } - current, err := sourceSnapshot(watched.watcher.root) - if err != nil { - return err - } - if current != watched.snapshot { - return nil - } - } -} - -// applyConflictResolutions applies one direction to every resource in each -// conflict group, including non-conflicted consumers of a shared attachment. +// applyConflictResolutions returns a copy of the plan in which each resolved +// resource has the action for its chosen side. The original plan stays as +// the reviewed record. func applyConflictResolutions(plan Plan, resolutions map[ResourceID]ConflictResolution) Plan { - // Clone the resource slice before changing actions so the reviewed plan - // remains an immutable record for post-review revalidation. - resolved := Plan{Resources: append([]PlannedResource(nil), plan.Resources...)} + resolved := Plan{Resources: slices.Clone(plan.Resources)} for index := range resolved.Resources { resource := &resolved.Resources[index] - resolution, ok := resolutions[resource.ID] - if !ok { - continue + if resolution, ok := resolutions[resource.ID]; ok { + resource.Action = resolvedConflictAction(*resource, resolution) } - resource.Action = resolvedConflictAction(*resource, resolution) } return resolved } -// resolvedConflictAction returns the operation needed for the chosen side to win. +// resolvedConflictAction returns the action that makes the chosen side win. func resolvedConflictAction(resource PlannedResource, resolution ConflictResolution) Action { switch resolution { case ConflictUseLaunchDarkly: @@ -341,13 +307,14 @@ func resolvedConflictAction(resource PlannedResource, resolution ConflictResolut } return ActionUpdateLocal case ConflictUseLocal: - if resource.Local == nil { + switch { + case resource.Local == nil: return ActionArchiveServer - } - if resource.Server == nil { + case resource.Server == nil: return ActionCreateServer + default: + return ActionUpdateServer } - return ActionUpdateServer default: return ActionConflict } diff --git a/internal/sync/prompt/conflict_test.go b/internal/sync/prompt/conflict_test.go index 0fa38cf5..f87b2f76 100644 --- a/internal/sync/prompt/conflict_test.go +++ b/internal/sync/prompt/conflict_test.go @@ -112,8 +112,8 @@ func TestApplyLocalChangeRestoresMissingConflictFile(t *testing.T) { } } assert.True(t, restored.Upsert) - require.Len(t, restored.Attachments, 1) - assert.Equal(t, serverDescription, *restored.Attachments[0].Tool.Description) + require.Len(t, restored.Variation.Attachments, 1) + assert.Equal(t, serverDescription, *restored.Variation.Attachments[0].Tool.Description) } func TestWriteConflictChoice(t *testing.T) { @@ -220,28 +220,28 @@ func TestGroupConflictsDeduplicatesSharedAttachment(t *testing.T) { } func TestGroupConflictsConnectsMixedChangesAcrossSharedAttachments(t *testing.T) { - tool := attachmentID{projectKey: "project", kind: syncdomain.AttachmentTool, key: "search"} - skill := attachmentID{projectKey: "project", kind: syncdomain.AttachmentSkill, key: "support"} + tool := ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "search"} + skill := ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "support"} resources := []PlannedResource{ { ID: ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/first"}, - Action: ActionConflict, changedAttachments: []attachmentID{tool}, + Action: ActionConflict, changedAttachments: []ResourceID{tool}, }, { ID: ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/second"}, - Action: ActionConflict, changedAttachments: []attachmentID{tool, skill}, + Action: ActionConflict, changedAttachments: []ResourceID{tool, skill}, }, { ID: ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/third"}, - Action: ActionConflict, changedAttachments: []attachmentID{skill}, + Action: ActionConflict, changedAttachments: []ResourceID{skill}, }, { ID: ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/fourth"}, - Action: ActionUpdateServer, changedAttachments: []attachmentID{skill}, + Action: ActionUpdateServer, changedAttachments: []ResourceID{skill}, }, { ID: ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/error"}, - Action: ActionError, changedAttachments: []attachmentID{skill}, + Action: ActionError, changedAttachments: []ResourceID{skill}, }, } @@ -329,8 +329,8 @@ func TestRunWorkspaceSyncAbortsAttachmentConflictWithoutWriting(t *testing.T) { resources, err := synclocal.CompileWorkspace(root) require.NoError(t, err) require.Len(t, resources, 1) - require.Len(t, resources[0].Attachments, 1) - assert.Equal(t, "Changed locally", *resources[0].Attachments[0].Tool.Description) + require.Len(t, resources[0].Variation.Attachments, 1) + assert.Equal(t, "Changed locally", *resources[0].Variation.Attachments[0].Tool.Description) assert.Contains(t, output.String(), "Sync canceled; conflict left unresolved.") } @@ -360,8 +360,8 @@ func TestRunWorkspaceSyncUsesLaunchDarklyForAttachmentConflict(t *testing.T) { resources, err := synclocal.CompileWorkspace(root) require.NoError(t, err) require.Len(t, resources, 1) - require.Len(t, resources[0].Attachments, 1) - assert.Equal(t, "Changed in LaunchDarkly", *resources[0].Attachments[0].Tool.Description) + require.Len(t, resources[0].Variation.Attachments, 1) + assert.Equal(t, "Changed in LaunchDarkly", *resources[0].Variation.Attachments[0].Tool.Description) assert.Contains(t, output.String(), "Using LaunchDarkly.") } diff --git a/internal/sync/prompt/diff.go b/internal/sync/prompt/diff.go index 2e349ae5..4949a12b 100644 --- a/internal/sync/prompt/diff.go +++ b/internal/sync/prompt/diff.go @@ -1,615 +1,137 @@ package prompt import ( - "bytes" "encoding/json" - "fmt" - "io" - "os" - "reflect" "slices" - "strings" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" syncdomain "github.com/launchdarkly/ldcli/internal/sync" - "github.com/pmezard/go-difflib/difflib" - "golang.org/x/term" ) +// variationFieldDiff is the JSON of one part of a variation before and after +// the sync. A nil value means that the part does not exist. type variationFieldDiff struct { Before json.RawMessage `json:"before"` After json.RawMessage `json:"after"` } +// variationDiffFields maps a part name, such as "variation" or "tools", to its diff. type variationDiffFields map[string]variationFieldDiff -type renderedDiffSection struct { - title string - change string - before string - after string -} - -const ( - diffSectionIndent = 6 - diffBodyIndent = 8 -) - -// renderVariationDiff formats structured field changes as terminal or Markdown -// unified diffs, choosing side-by-side output when the terminal is wide enough. -func renderVariationDiff(fields variationDiffFields, outputKind string, width int, presentation variationDiffPresentation) (string, error) { - keys := make([]string, 0, len(fields)) - for key := range fields { - keys = append(keys, key) +// variationDiff returns the parts of the variation that differ. The tools and +// the skills have their own parts, so that the review shows their content. +func variationDiff(before, after *syncdomain.Variation) variationDiffFields { + if before == nil && after == nil { + return nil } - slices.Sort(keys) - var rendered strings.Builder - for _, key := range keys { - diff := fields[key] - if presentation.reverse { - diff.Before, diff.After = diff.After, diff.Before - } - sections, err := diffSections(key, diff, presentation) - if err != nil { - return "", err - } - for _, section := range sections { - diffLines, err := unifiedDiffLines(section.before, section.after, presentation.beforeLabel, presentation.afterLabel) - if err != nil { - return "", err - } - if key == "tools" || key == "skills" { - diffLines = slices.DeleteFunc(diffLines, func(line string) bool { - return strings.HasPrefix(line, "@@") - }) - } - if outputKind == "markdown" { - _, _ = fmt.Fprintf(&rendered, "\n#### %s (%s)\n\n", section.title, section.change) - content := strings.Join(diffLines, "\n") - fence := markdownCodeFence(content) - _, _ = fmt.Fprintf(&rendered, "%sdiff\n%s\n%s\n", fence, content, fence) - continue - } - sectionTitle := fmt.Sprintf("%s (%s)", section.title, section.change) - if width > 0 { - sectionTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("67")).Bold(true).Render(sectionTitle) - } - _, _ = fmt.Fprintf(&rendered, "\n%s%s\n\n", strings.Repeat(" ", diffSectionIndent), sectionTitle) - if width >= 100 { - rendered.WriteString(renderSideBySideUnifiedDiff(diffLines, width)) - } else { - rendered.WriteString(renderUnifiedDiff(diffLines, width > 0)) - } + fields := variationDiffFields{} + addField := func(name string, before, after json.RawMessage) { + if string(before) != string(after) { + fields[name] = variationFieldDiff{Before: before, After: after} } } - return rendered.String(), nil -} + addField("variation", variationJSON(before), variationJSON(after)) + beforeTools, beforeSkills := attachmentJSON(before) + afterTools, afterSkills := attachmentJSON(after) + addField("tools", beforeTools, afterTools) + addField("skills", beforeSkills, afterSkills) -// markdownCodeFence returns a fence that cannot close inside its content. -func markdownCodeFence(content string) string { - longestRun := 0 - currentRun := 0 - for _, character := range content { - if character != '`' { - currentRun = 0 - continue - } - currentRun++ - if currentRun > longestRun { - longestRun = currentRun - } + if len(fields) == 0 { + return nil } - fenceLength := 3 - if longestRun >= fenceLength { - fenceLength = longestRun + 1 - } - return strings.Repeat("`", fenceLength) + return fields } -func diffSections(field string, diff variationFieldDiff, presentation variationDiffPresentation) ([]renderedDiffSection, error) { - switch field { - case "tools": - return attachmentDiffSections[syncdomain.Tool]("Tool", diff, func(tool syncdomain.Tool) string { return tool.Key }, formatToolDetails) - case "skills": - return attachmentDiffSections[syncdomain.Skill]( - "Skill", - diff, - func(skill syncdomain.Skill) string { return skill.Key }, - formatSkillDetails, - ) +// variationJSON returns the variation without its attachment references. The +// "tools" and "skills" parts show those changes. +func variationJSON(variation *syncdomain.Variation) json.RawMessage { + if variation == nil { + return nil } - - before, err := formatDiffValue(diff.Before, presentation.beforeLabel, "") - if err != nil { - return nil, err - } - after, err := formatDiffValue(diff.After, presentation.afterLabel, presentation.missingAfter) - if err != nil { - return nil, err - } - return []renderedDiffSection{{ - title: diffFieldTitle(field), change: diffChangeKind(diff.Before, diff.After), - before: before, after: after, - }}, nil + withoutRefs := *variation + withoutRefs.Tools, withoutRefs.Skills = nil, nil + data, _ := json.Marshal(withoutRefs) + return data } -func attachmentDiffSections[T any]( - kind string, - diff variationFieldDiff, - key func(T) string, - format func(T) string, -) ([]renderedDiffSection, error) { - before, err := decodeAttachmentDiffItems[T](diff.Before) - if err != nil { - return nil, fmt.Errorf("format %s diff: %w", strings.ToLower(kind), err) - } - after, err := decodeAttachmentDiffItems[T](diff.After) - if err != nil { - return nil, fmt.Errorf("format %s diff: %w", strings.ToLower(kind), err) - } - - beforeByKey := make(map[string]T, len(before)) - afterByKey := make(map[string]T, len(after)) - keys := make([]string, 0, len(before)+len(after)) - for _, item := range before { - beforeByKey[key(item)] = item - keys = append(keys, key(item)) - } - for _, item := range after { - afterByKey[key(item)] = item - if _, exists := beforeByKey[key(item)]; !exists { - keys = append(keys, key(item)) - } - } - slices.Sort(keys) - - sections := make([]renderedDiffSection, 0, len(keys)) - for _, itemKey := range keys { - beforeItem, beforeExists := beforeByKey[itemKey] - afterItem, afterExists := afterByKey[itemKey] - if beforeExists && afterExists && reflect.DeepEqual(beforeItem, afterItem) { - continue - } - - beforeValue, afterValue := "(not attached)", "(not attached)" - if beforeExists { - beforeValue = format(beforeItem) - } - if afterExists { - afterValue = format(afterItem) - } - sections = append(sections, renderedDiffSection{ - title: fmt.Sprintf("%s %q", kind, itemKey), - change: attachmentChangeKind(beforeExists, afterExists), - before: beforeValue, - after: afterValue, - }) - } - return sections, nil -} - -func decodeAttachmentDiffItems[T any](value json.RawMessage) ([]T, error) { - if len(value) == 0 || bytes.Equal(value, []byte("null")) { +// attachmentJSON returns the canonical content of the tools and of the skills. +func attachmentJSON(variation *syncdomain.Variation) (tools, skills json.RawMessage) { + if variation == nil { return nil, nil } - var items []T - if err := json.Unmarshal(value, &items); err != nil { - return nil, err - } - return items, nil -} - -func diffChangeKind(before, after json.RawMessage) string { - switch { - case len(before) == 0: - return "added" - case len(after) == 0: - return "removed" - default: - return "changed" - } -} - -func attachmentChangeKind(before, after bool) string { - switch { - case !before: - return "added" - case !after: - return "removed" - default: - return "changed" - } -} - -// unifiedDiffLines delegates line-level comparison to go-difflib while keeping -// labels and context consistent across output modes. -func unifiedDiffLines(before, after, beforeLabel, afterLabel string) ([]string, error) { - diff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ - A: diffInputLines(before), - B: diffInputLines(after), - FromFile: beforeLabel, - ToFile: afterLabel, - Context: 1, - }) - if err != nil { - return nil, fmt.Errorf("build variation diff: %w", err) - } - return strings.Split(strings.TrimSuffix(diff, "\n"), "\n"), nil -} - -func diffFieldTitle(field string) string { - switch field { - case "variation": - return "Variation content" - case "attachment versions": - return "Attachment versions for this variation" - default: - return field - } -} - -// diffInputLines gives every logical line the terminator expected by difflib. -func diffInputLines(value string) []string { - lines := strings.Split(value, "\n") - for index := range lines { - lines[index] += "\n" - } - return lines -} - -// renderUnifiedDiff renders a conventional single-column diff and highlights -// paired replacements more precisely than independent added/removed lines. -func renderUnifiedDiff(lines []string, color bool) string { - var rendered strings.Builder - for index := 0; index < len(lines); { - change := scanDiffChange(lines, index) - if len(change.removed) != 0 && len(change.added) != 0 { - // Clone scanned lines before styling so later rendering still sees - // the original diff text. - styledRemoved := append([]string(nil), change.removed...) - styledAdded := append([]string(nil), change.added...) - pairs := min(len(change.removed), len(change.added)) - for pair := 0; pair < pairs; pair++ { - if color { - styledRemoved[pair], styledAdded[pair] = renderChangedLinePair( - change.removed[pair], - change.added[pair], - ) - } - } - for index := pairs; index < len(styledRemoved); index++ { - styledRemoved[index] = styleDiffLine(styledRemoved[index], color) - } - for index := pairs; index < len(styledAdded); index++ { - styledAdded[index] = styleDiffLine(styledAdded[index], color) - } - for _, line := range append(styledRemoved, styledAdded...) { - _, _ = fmt.Fprintf(&rendered, "%s%s\n", strings.Repeat(" ", diffBodyIndent), line) - } - index = change.next - continue - } - _, _ = fmt.Fprintf( - &rendered, - "%s%s\n", - strings.Repeat(" ", diffBodyIndent), - styleDiffLine(lines[index], color), - ) - index++ - } - return rendered.String() -} - -// renderSideBySideUnifiedDiff aligns removed and added lines into equal-width -// columns while retaining unified-diff headers and hunks. -func renderSideBySideUnifiedDiff(lines []string, width int) string { - if len(lines) < 2 { - return renderUnifiedDiff(lines, true) - } - - const ( - columnGap = 2 - ) - columnWidth := (width - diffBodyIndent - columnGap) / 2 - cellStyle := lipgloss.NewStyle().Width(columnWidth) - - var rendered strings.Builder - writeRow := func(before, after string) { - before = ansi.Wordwrap(before, columnWidth, ",:") - after = ansi.Wordwrap(after, columnWidth, ",:") - row := lipgloss.JoinHorizontal( - lipgloss.Top, - cellStyle.Render(before), - strings.Repeat(" ", columnGap), - cellStyle.Render(after), - ) - _, _ = fmt.Fprintf( - &rendered, - "%s\n", - indentBlock(row, diffBodyIndent), - ) - } - - writeRow(styleDiffLine(lines[0], true), styleDiffLine(lines[1], true)) - for index := 2; index < len(lines); { - if strings.HasPrefix(lines[index], "@@") { - _, _ = fmt.Fprintf( - &rendered, - "%s%s\n", - strings.Repeat(" ", diffBodyIndent), - styleDiffLine(lines[index], true), - ) - index++ - continue - } - - change := scanDiffChange(lines, index) - if len(change.removed) != 0 || len(change.added) != 0 { - for pair := 0; pair < max(len(change.removed), len(change.added)); pair++ { - var beforeLine, afterLine string - switch { - case pair < len(change.removed) && pair < len(change.added): - beforeLine, afterLine = renderChangedLinePair( - change.removed[pair], - change.added[pair], - ) - case pair < len(change.removed): - beforeLine = styleDiffLine(change.removed[pair], true) - default: - afterLine = styleDiffLine(change.added[pair], true) - } - writeRow(beforeLine, afterLine) + var toolContent []syncdomain.Tool + var skillContent []syncdomain.Skill + for _, attachment := range variation.Attachments { + canonical := syncdomain.CanonicalAttachment(attachment) + switch { + case canonical.Tool != nil: + toolContent = append(toolContent, *canonical.Tool) + case canonical.Skill != nil: + skillContent = append(skillContent, *canonical.Skill) + } + } + if len(toolContent) != 0 { + tools, _ = json.Marshal(toolContent) + } + if len(skillContent) != 0 { + skills, _ = json.Marshal(skillContent) + } + return tools, skills +} + +// attachmentPinDiff reports the references in a server variation that do not +// pin the latest version. It returns the current and the latest versions. +func attachmentPinDiff(variation *syncdomain.Variation) (current, latest json.RawMessage, stale bool) { + if variation == nil { + return nil, nil, false + } + currentPins := map[string]map[string]int{"tools": {}, "skills": {}} + latestPins := map[string]map[string]int{"tools": {}, "skills": {}} + for _, kind := range syncdomain.AttachmentKinds { + group := string(kind) + "s" + for _, ref := range variation.Refs(kind) { + if attachment, ok := variation.Attachment(kind, ref.Key); ok && attachment.Version != ref.Version { + currentPins[group][ref.Key] = ref.Version + latestPins[group][ref.Key] = attachment.Version + stale = true } - index = change.next - continue } - - context := styleDiffLine(lines[index], true) - writeRow(context, context) - index++ - } - return rendered.String() -} - -type diffChange struct { - removed []string - added []string - next int -} - -// scanDiffChange groups adjacent removed and added lines into one replacement block. -func scanDiffChange(lines []string, start int) diffChange { - removedEnd := start - for removedEnd < len(lines) && isRemovedDiffLine(lines[removedEnd]) { - removedEnd++ - } - - addedEnd := removedEnd - for addedEnd < len(lines) && isAddedDiffLine(lines[addedEnd]) { - addedEnd++ } - - return diffChange{ - removed: lines[start:removedEnd], - added: lines[removedEnd:addedEnd], - next: addedEnd, + if !stale { + return nil, nil, false } + current, _ = json.Marshal(currentPins) + latest, _ = json.Marshal(latestPins) + return current, latest, true } -// indentBlock applies the same left margin to every rendered line. -func indentBlock(value string, spaces int) string { - prefix := strings.Repeat(" ", spaces) - return prefix + strings.ReplaceAll(value, "\n", "\n"+prefix) -} - -// isRemovedDiffLine distinguishes content removals from the --- file header. -func isRemovedDiffLine(line string) bool { - return strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") -} - -// isAddedDiffLine distinguishes content additions from the +++ file header. -func isAddedDiffLine(line string) bool { - return strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") -} - -// styleDiffLine applies semantic colors to headers, hunks, context, and changes. -func styleDiffLine(line string, color bool) string { - if !color { - return line - } - switch { - case strings.HasPrefix(line, "---"), isRemovedDiffLine(line): - return lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(line) - case strings.HasPrefix(line, "+++"), isAddedDiffLine(line): - return lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(line) - case strings.HasPrefix(line, "@@"): - return lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Render(line) - default: - return lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(line) - } -} - -// renderChangedLinePair highlights only the changed span within paired lines. -func renderChangedLinePair(before, after string) (string, string) { - if !isRemovedDiffLine(before) || !isAddedDiffLine(after) { - return styleDiffLine(before, true), styleDiffLine(after, true) - } - - prefix, removed, added, suffix := changedParts(before[1:], after[1:]) - removedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) - addedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) - removedHighlight := removedStyle. - Background(lipgloss.Color("52")). - Bold(true) - addedHighlight := addedStyle. - Background(lipgloss.Color("22")). - Bold(true) - - return removedStyle.Render("-"+prefix) + - removedHighlight.Render(removed) + - removedStyle.Render(suffix), - addedStyle.Render("+"+prefix) + - addedHighlight.Render(added) + - addedStyle.Render(suffix) -} - -// changedParts separates two lines into their shared prefix, changed middle, -// and shared suffix using runes rather than bytes. -func changedParts(before, after string) (prefix, removed, added, suffix string) { - beforeRunes := []rune(before) - afterRunes := []rune(after) - prefixLength := 0 - for prefixLength < min(len(beforeRunes), len(afterRunes)) && - beforeRunes[prefixLength] == afterRunes[prefixLength] { - prefixLength++ - } - - suffixLength := 0 - for suffixLength < len(beforeRunes)-prefixLength && - suffixLength < len(afterRunes)-prefixLength && - beforeRunes[len(beforeRunes)-1-suffixLength] == - afterRunes[len(afterRunes)-1-suffixLength] { - suffixLength++ - } +// changedAttachmentIDs returns the tools and skills whose content differs +// between the server and the local variation. +func changedAttachmentIDs(resource PlannedResource) []ResourceID { + before := attachmentsByID(resource.ID.ProjectKey, resource.Server) + after := attachmentsByID(resource.ID.ProjectKey, resource.Local) - beforeChangeEnd := len(beforeRunes) - suffixLength - afterChangeEnd := len(afterRunes) - suffixLength - return string(beforeRunes[:prefixLength]), - string(beforeRunes[prefixLength:beforeChangeEnd]), - string(afterRunes[prefixLength:afterChangeEnd]), - string(beforeRunes[beforeChangeEnd:]) -} - -// formatDiffValue pretty-prints JSON and substitutes readable absence markers -// for missing local or LaunchDarkly resources. -func formatDiffValue(value json.RawMessage, label string, missingValue string) (string, error) { - if len(value) == 0 || bytes.Equal(value, []byte("null")) { - if missingValue != "" { - return missingValue, nil + var changed []ResourceID + for id, attachment := range before { + if other, exists := after[id]; !exists || !syncdomain.SameAttachmentContent(attachment, other) { + changed = append(changed, id) } - if strings.HasPrefix(label, "LaunchDarkly") { - return "(does not exist in LaunchDarkly)", nil - } - return "(does not exist locally)", nil - } - var formatted bytes.Buffer - if err := json.Indent(&formatted, value, "", " "); err != nil { - return "", fmt.Errorf("format variation diff: %w", err) - } - return formatted.String(), nil -} - -func formatToolDetails(tool syncdomain.Tool) string { - var rendered strings.Builder - if tool.Description != nil && *tool.Description != "" { - _, _ = fmt.Fprintf(&rendered, "Description: %s", *tool.Description) - } - writeDiffValue(&rendered, "Schema", tool.Schema, 0) - if len(tool.CustomParameters) != 0 { - writeDiffValue(&rendered, "Custom parameters", tool.CustomParameters, 0) - } - if len(tool.Tags) != 0 { - writeDiffValue(&rendered, "Tags", tool.Tags, 0) - } - return strings.TrimPrefix(rendered.String(), "\n") -} - -func formatSkillDetails(skill syncdomain.Skill) string { - var rendered strings.Builder - if skill.Description != "" { - _, _ = fmt.Fprintf(&rendered, "Description: %s", skill.Description) - } - if skill.Markdown != "" { - if rendered.Len() > 0 { - rendered.WriteString("\n") - } - rendered.WriteString("Markdown:\n") - rendered.WriteString(indentBlock(strings.TrimSpace(skill.Markdown), 2)) - } - if rendered.Len() == 0 { - return "(attached)" } - return rendered.String() -} - -func writeDiffValue(rendered *strings.Builder, label string, value any, indent int) { - padding := strings.Repeat(" ", indent) - switch value := value.(type) { - case map[string]any: - if len(value) == 0 { - _, _ = fmt.Fprintf(rendered, "\n%s%s: {}", padding, label) - return - } - _, _ = fmt.Fprintf(rendered, "\n%s%s:", padding, label) - writeDiffMap(rendered, value, indent+2) - case []string: - _, _ = fmt.Fprintf(rendered, "\n%s%s:", padding, label) - for _, item := range value { - _, _ = fmt.Fprintf(rendered, "\n%s- %s", strings.Repeat(" ", indent+2), item) - } - case []any: - if len(value) == 0 { - _, _ = fmt.Fprintf(rendered, "\n%s%s: []", padding, label) - return + for id := range after { + if _, exists := before[id]; !exists { + changed = append(changed, id) } - _, _ = fmt.Fprintf(rendered, "\n%s%s:", padding, label) - writeDiffList(rendered, value, indent+2) - case nil: - _, _ = fmt.Fprintf(rendered, "\n%s%s: null", padding, label) - default: - _, _ = fmt.Fprintf(rendered, "\n%s%s: %v", padding, label, value) } + slices.SortFunc(changed, syncdomain.CompareResourceIDs) + return changed } -func writeDiffMap(rendered *strings.Builder, values map[string]any, indent int) { - keys := make([]string, 0, len(values)) - for key := range values { - keys = append(keys, key) - } - slices.Sort(keys) - - for _, key := range keys { - writeDiffValue(rendered, key, values[key], indent) - } -} - -func writeDiffList(rendered *strings.Builder, values []any, indent int) { - padding := strings.Repeat(" ", indent) - for _, value := range values { - switch nested := value.(type) { - case map[string]any: - if len(nested) == 0 { - _, _ = fmt.Fprintf(rendered, "\n%s- {}", padding) - continue - } - _, _ = fmt.Fprintf(rendered, "\n%s-", padding) - writeDiffMap(rendered, nested, indent+2) - case []any: - if len(nested) == 0 { - _, _ = fmt.Fprintf(rendered, "\n%s- []", padding) - continue - } - _, _ = fmt.Fprintf(rendered, "\n%s-", padding) - writeDiffList(rendered, nested, indent+2) - case nil: - _, _ = fmt.Fprintf(rendered, "\n%s- null", padding) - default: - _, _ = fmt.Fprintf(rendered, "\n%s- %v", padding, value) +func attachmentsByID(projectKey string, variation *syncdomain.Variation) map[ResourceID]syncdomain.Attachment { + attachments := map[ResourceID]syncdomain.Attachment{} + if variation != nil { + for _, attachment := range variation.Attachments { + attachments[attachment.ID(projectKey)] = attachment } } -} - -// terminalWidth returns zero for redirected output or unavailable terminal metadata. -func terminalWidth(out io.Writer) int { - file, ok := out.(*os.File) - if !ok || !term.IsTerminal(int(file.Fd())) { - return 0 - } - width, _, err := term.GetSize(int(file.Fd())) - if err != nil { - return 0 - } - return width + return attachments } diff --git a/internal/sync/prompt/execute.go b/internal/sync/prompt/execute.go index e58e94cc..7180a04b 100644 --- a/internal/sync/prompt/execute.go +++ b/internal/sync/prompt/execute.go @@ -1,11 +1,8 @@ package prompt import ( - "encoding/json" "errors" "fmt" - "slices" - "strings" syncdomain "github.com/launchdarkly/ldcli/internal/sync" syncapi "github.com/launchdarkly/ldcli/internal/sync/api" @@ -13,209 +10,60 @@ import ( syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" ) -// attachmentResolver versions shared dependencies once per execution and -// reuses both successful results and failures across every consumer. -type attachmentResolver struct { - client syncapi.Client - resolved map[attachmentID]syncdomain.Attachment - failures map[attachmentID]error -} - -func newAttachmentResolver(client syncapi.Client) *attachmentResolver { - return &attachmentResolver{ - client: client, - resolved: make(map[attachmentID]syncdomain.Attachment), - failures: make(map[attachmentID]error), - } -} - -func (resolver *attachmentResolver) resolveVariation(projectKey string, variation syncdomain.Variation) (syncdomain.Variation, error) { - // A value copy still shares slice backing arrays with the reviewed plan; - // clone pins before assigning server versions so review state stays immutable. - variation.Tools = slices.Clone(variation.Tools) - variation.Skills = slices.Clone(variation.Skills) - if err := resolver.resolveReferences(projectKey, syncdomain.AttachmentTool, variation.Tools, variation); err != nil { - return syncdomain.Variation{}, err - } - if err := resolver.resolveReferences(projectKey, syncdomain.AttachmentSkill, variation.Skills, variation); err != nil { - return syncdomain.Variation{}, err - } - return variation, nil -} - -func (resolver *attachmentResolver) resolveReferences( - projectKey string, - kind syncdomain.AttachmentKind, - references []syncdomain.AttachmentRef, - variation syncdomain.Variation, -) error { - for index := range references { - local, ok := variation.Attachment(kind, references[index].Key) - if !ok { - return fmt.Errorf("%s %q content is missing", kind, references[index].Key) - } - resolved, err := resolver.resolve(projectKey, local) - if err != nil { - return err - } - references[index].Version = resolved.Version - } - return nil -} - -func (resolver *attachmentResolver) resolve(projectKey string, local syncdomain.Attachment) (syncdomain.Attachment, error) { - id := attachmentID{projectKey: projectKey, kind: local.Kind, key: local.Key()} - if err, failed := resolver.failures[id]; failed { - return syncdomain.Attachment{}, err - } - if attachment, ok := resolver.resolved[id]; ok { - return attachment, nil - } - - remote, err := resolver.client.ReadAttachment(projectKey, local.Kind, local.Key()) - var mutationErr error - switch { - case err == nil && sameAttachmentContent(local, remote): - resolver.resolved[id] = remote - return remote, nil - case err == nil: - mutationErr = resolver.client.UpdateAttachment(projectKey, local) - case syncapi.IsNotFound(err) && local.Kind == syncdomain.AttachmentTool && local.Upsert: - mutationErr = resolver.client.CreateAttachment(projectKey, local) - case syncapi.IsNotFound(err) && local.Kind == syncdomain.AttachmentTool: - err = fmt.Errorf( - "tool %q does not exist in LaunchDarkly; add \"upsert\": true to its local JSON file to create it during sync", - local.Key(), - ) - resolver.failures[id] = err - return syncdomain.Attachment{}, err - default: - resolver.failures[id] = err - return syncdomain.Attachment{}, err - } - - if mutationErr != nil && !syncapi.MutationMayHaveSucceeded(mutationErr) { - resolver.failures[id] = mutationErr - return syncdomain.Attachment{}, mutationErr - } - - // Version allocation belongs to LaunchDarkly, so trust only a subsequent - // read. It also verifies writes whose response was lost or malformed. - observed, err := resolver.client.ReadAttachment(projectKey, local.Kind, local.Key()) - if err != nil { - err = errors.Join(mutationErr, err) - resolver.failures[id] = err - return syncdomain.Attachment{}, err - } - if !sameAttachmentContent(local, observed) { - err = mutationErr - if err == nil { - err = fmt.Errorf("%s %q changed concurrently", local.Kind, local.Key()) - } - resolver.failures[id] = err - return syncdomain.Attachment{}, err - } - resolver.resolved[id] = observed - return observed, nil -} - -func sameAttachmentContent(left, right syncdomain.Attachment) bool { - leftJSON, _ := json.Marshal(syncdomain.CanonicalAttachment(left)) - rightJSON, _ := json.Marshal(syncdomain.CanonicalAttachment(right)) - return string(leftJSON) == string(rightJSON) -} - -// variationForServerUpdate distinguishes omitted attachment fields from an -// explicit request to detach every existing item. -func variationForServerUpdate(local syncdomain.Variation, server *syncdomain.Variation) syncdomain.Variation { - if server == nil { - return local - } - if local.Tools == nil && len(server.Tools) != 0 { - local.Tools = []syncdomain.AttachmentRef{} - } - if local.Skills == nil && len(server.Skills) != 0 { - local.Skills = []syncdomain.AttachmentRef{} - } - return local -} - -func variationPinnedToLatest(variation syncdomain.Variation) syncdomain.Variation { - // A value copy still shares slice backing arrays with the reviewed plan; - // clone pins before assigning latest versions. - variation.Tools = slices.Clone(variation.Tools) - variation.Skills = slices.Clone(variation.Skills) - for index := range variation.Tools { - if attachment, ok := variation.Attachment(syncdomain.AttachmentTool, variation.Tools[index].Key); ok { - variation.Tools[index].Version = attachment.Version - } - } - for index := range variation.Skills { - if attachment, ok := variation.Attachment(syncdomain.AttachmentSkill, variation.Skills[index].Key); ok { - variation.Skills[index].Version = attachment.Version - } - } - return variation -} - -// executePlan applies each independently executable resource and advances the -// manifest only for resources that succeed. +// executePlan applies each action of the plan, and returns the outcomes and +// the new manifest. The manifest records only the actions that succeed. The +// manifest argument does not change. localFiles has each local variation as +// its file stores it. func executePlan( repositoryRoot string, localStore synclocal.Store, client syncapi.Client, manifest syncmanifest.Manifest, plan Plan, - localFiles localFileResourcesByID, + localFiles map[ResourceID]syncdomain.SyncedResource, ) ([]ResourceOutcome, syncmanifest.Manifest, error) { - // Conflict resolution is a plan-wide decision. Refuse every mutation until - // all conflicts have a direction so a shared attachment cannot advance - // while one of its consumers remains unresolved. + // A conflict choice applies to the full plan. Write nothing until every + // conflict has a choice, so that a shared attachment cannot change while + // one of its variations has no choice. if err := plan.BlockingError(); err != nil { return nil, manifest, err } - // Keep the reviewed baseline immutable while successful resources advance - // the result manifest independently. - manifest.Resources = append([]syncmanifest.Resource(nil), manifest.Resources...) - + next := manifest.Clone() outcomes := make([]ResourceOutcome, 0, len(plan.Resources)) var failures []error attachments := newAttachmentResolver(client) + fail := func(outcome *ResourceOutcome, err error) { + outcome.Status, outcome.Error = OutcomeFailed, err.Error() + failures = append(failures, fmt.Errorf("%s: %w", outcome.ID, err)) + } for _, resource := range plan.Resources { outcome := ResourceOutcome{ID: resource.ID, Action: resource.Action, Status: OutcomeSucceeded} - - switch resource.Action { - case ActionInSync: - // The manifest already represents this state. - case ActionUpdateManifest: - manifest.SetFingerprint(resource.ID, resource.LocalFingerprint) - case ActionRemoveManifest: - manifest.Remove(resource.ID) - case ActionCreateServer, ActionUpdateServer, ActionArchiveServer, ActionUpdateLocal, ActionDeleteLocal: - if err := applyResourceChange( - repositoryRoot, - localStore, - client, - attachments, - resource, - localFiles[resource.ID], - ); err != nil { - outcome.Status, outcome.Error = OutcomeFailed, err.Error() - failures = append(failures, fmt.Errorf("%s/%s: %w", resource.ID.ProjectKey, resource.ID.LookupKey, err)) + switch { + case resource.Action == ActionInSync: + case resource.Action == ActionUpdateManifest: + next.SetFingerprint(resource.ID, resource.LocalFingerprint) + case resource.Action == ActionRemoveManifest: + next.Remove(resource.ID) + case resource.Action.changesServer() || resource.Action.changesLocal(): + var localFile *syncdomain.Variation + if file, ok := localFiles[resource.ID]; ok { + localFile = &file.Variation + } + if err := applyResourceChange(repositoryRoot, localStore, client, attachments, resource, localFile); err != nil { + fail(&outcome, err) break } - recordSuccessfulChange(&manifest, resource) + recordSuccessfulChange(&next, resource) default: outcome.Status, outcome.Error = OutcomeSkipped, "resource is not executable" } if outcome.Status == OutcomeSucceeded { if variation := attachmentManifestState(resource); variation != nil { - if err := manifest.SetAttachments(resource.ID.ProjectKey, variation.Attachments); err != nil { - outcome.Status, outcome.Error = OutcomeFailed, err.Error() - failures = append(failures, err) + if err := next.SetAttachments(resource.ID.ProjectKey, variation.Attachments); err != nil { + fail(&outcome, err) } } } @@ -223,13 +71,18 @@ func executePlan( } if len(failures) == 0 { - if err := pruneAttachmentManifest(repositoryRoot, &manifest); err != nil { + variations, err := compileWorkspace(repositoryRoot) + if err != nil { failures = append(failures, err) + } else { + next.RemoveUnusedAttachments(variations) } } - return outcomes, manifest, errors.Join(failures...) + return outcomes, next, errors.Join(failures...) } +// attachmentManifestState returns the variation whose attachments the +// manifest records after the action, or nil. func attachmentManifestState(resource PlannedResource) *syncdomain.Variation { switch resource.Action { case ActionInSync, ActionUpdateManifest, ActionCreateServer, ActionUpdateServer: @@ -241,50 +94,38 @@ func attachmentManifestState(resource PlannedResource) *syncdomain.Variation { } } -func pruneAttachmentManifest(repositoryRoot string, manifest *syncmanifest.Manifest) error { - resources, err := synclocal.CompileWorkspace(repositoryRoot) - if errors.Is(err, synclocal.ErrNoDirectory) { - resources = nil - err = nil - } - if err != nil { - return err - } - referenced := make(map[syncdomain.ResourceID]struct{}) - for _, resource := range resources { - for _, attachment := range resource.Attachments { - referenced[syncdomain.ResourceID{ - Kind: syncdomain.Kind(attachment.Kind), - ProjectKey: resource.ProjectKey, - LookupKey: attachment.Key(), - }] = struct{}{} - } +// recordSuccessfulChange records the state that a completed action chose. +func recordSuccessfulChange(manifest *syncmanifest.Manifest, resource PlannedResource) { + switch { + case resource.Action == ActionArchiveServer || resource.Action == ActionDeleteLocal: + manifest.Remove(resource.ID) + case resource.Action.changesServer(): + manifest.SetFingerprint(resource.ID, resource.LocalFingerprint) + default: + manifest.SetFingerprint(resource.ID, resource.ServerFingerprint) } - manifest.RemoveUnreferencedAttachments(referenced) - return nil } -// applyResourceChange applies one local or server mutation from the plan -// revalidated after review. +// applyResourceChange applies one action that writes to LaunchDarkly or to a +// local file. localFile is the variation as its file stores it, or nil. func applyResourceChange( repositoryRoot string, localStore synclocal.Store, client syncapi.Client, attachments *attachmentResolver, resource PlannedResource, - localFile syncdomain.SyncedResource, + localFile *syncdomain.Variation, ) error { - if changesServer(resource.Action) { + if resource.Action.changesServer() { return applyServerChange(client, attachments, resource) } - // Local files can omit inherited model fields. Keep the complete server - // form for any attachment pin refresh that follows the local write. - serverVariation := resource.Server + + // The local write uses the form of the local file, which can leave out + // model values from the model config. A pin update below needs the + // complete variation, so keep it. + server := resource.Server if resource.Action == ActionUpdateLocal { - variation, err := variationForLocalFile(*resource.Server, resource.Local, localFile) - if err != nil { - return err - } + variation := variationForLocalFile(*resource.Server, localFile, resource.Local) resource.Server = &variation } if err := applyLocalChange(localStore, resource); err != nil { @@ -293,181 +134,160 @@ func applyResourceChange( if err := verifyLocalResult(repositoryRoot, resource); err != nil { return err } - if resource.ServerHasStaleAttachmentPins && serverVariation != nil { - configKey, _, err := splitVariationLookupKey(resource.ID.LookupKey) + + // The local file now matches LaunchDarkly. If LaunchDarkly pins an older + // attachment version, update the pin too. + if resource.ServerHasStaleAttachmentPins && server != nil { + configKey, _, err := resource.ID.VariationKeys() if err != nil { return err } - return client.UpdateVariation(resource.ID.ProjectKey, configKey, variationPinnedToLatest(*serverVariation)) + pinned, err := server.PinnedToLatest() + if err != nil { + return err + } + return client.UpdateVariation(resource.ID.ProjectKey, configKey, pinned) } return nil } -// recordSuccessfulChange updates the manifest to the state selected by the -// completed action. -func recordSuccessfulChange(manifest *syncmanifest.Manifest, resource PlannedResource) { - switch { - case resource.Action == ActionArchiveServer || resource.Action == ActionDeleteLocal: - manifest.Remove(resource.ID) - case changesServer(resource.Action): - manifest.SetFingerprint(resource.ID, resource.LocalFingerprint) - default: - manifest.SetFingerprint(resource.ID, resource.ServerFingerprint) - } -} - -// applyServerChange performs one variation mutation through the existing -// public config APIs. +// applyServerChange writes one variation to LaunchDarkly. If the write fails +// without a response, it reads the variation to find whether the write +// succeeded. func applyServerChange(client syncapi.Client, attachments *attachmentResolver, resource PlannedResource) error { - configKey, variationKey, err := splitVariationLookupKey(resource.ID.LookupKey) + configKey, variationKey, err := resource.ID.VariationKeys() if err != nil { return err } + projectKey := resource.ID.ProjectKey - var mutationErr error + var writeErr error switch resource.Action { case ActionCreateServer: - variation, err := attachments.resolveVariation(resource.ID.ProjectKey, *resource.Local) + variation, err := attachments.resolveVariation(projectKey, *resource.Local) if err != nil { return err } - mutationErr = client.CreateVariation(resource.ID.ProjectKey, configKey, variation) + writeErr = client.CreateVariation(projectKey, configKey, variation) case ActionUpdateServer: - variation, err := attachments.resolveVariation( - resource.ID.ProjectKey, - variationForServerUpdate(*resource.Local, resource.Server), - ) + variation, err := attachments.resolveVariation(projectKey, withExplicitDetach(*resource.Local, resource.Server)) if err != nil { return err } - mutationErr = client.UpdateVariation(resource.ID.ProjectKey, configKey, variation) + writeErr = client.UpdateVariation(projectKey, configKey, variation) case ActionArchiveServer: - mutationErr = client.ArchiveVariation(resource.ID.ProjectKey, configKey, variationKey) - } - if mutationErr == nil { - return nil + writeErr = client.ArchiveVariation(projectKey, configKey, variationKey) + default: + return fmt.Errorf("action %q does not change LaunchDarkly", resource.Action) } - if !syncapi.MutationMayHaveSucceeded(mutationErr) { - return mutationErr + if writeErr == nil || !syncapi.MutationMayHaveSucceeded(writeErr) { + return writeErr } - // A network error can hide a successful write, so re-read only when the - // mutation result is uncertain. - state, readErr := client.ReadVariation(resource.ID.ProjectKey, configKey, variationKey) - if readErr != nil { - return errors.Join(mutationErr, fmt.Errorf("verify server variation: %w", readErr)) + state, err := client.ReadVariation(projectKey, configKey, variationKey) + if err != nil { + return errors.Join(writeErr, fmt.Errorf("verify server variation: %w", err)) } - - actualFingerprint := "" + actual := "" if state.Exists { - if err := hydrateServerAttachments(client, resource.ID.ProjectKey, &state.Variation); err != nil { - return errors.Join(mutationErr, err) + if err := newAttachmentCache(client).hydrate(projectKey, &state.Variation); err != nil { + return errors.Join(writeErr, err) } - actualFingerprint, readErr = syncdomain.FingerprintVariation(resource.ID.ProjectKey, resource.ID.LookupKey, state.Variation) - if readErr != nil { - return errors.Join(mutationErr, readErr) + if actual, err = syncdomain.FingerprintVariation(projectKey, resource.ID.LookupKey, state.Variation); err != nil { + return errors.Join(writeErr, err) } } - expectedFingerprint := resource.LocalFingerprint + expected := resource.LocalFingerprint if resource.Action == ActionArchiveServer { - expectedFingerprint = "" + expected = "" } - switch actualFingerprint { - case expectedFingerprint: + switch actual { + case expected: return nil case resource.ServerFingerprint: - return mutationErr + return writeErr + default: + return fmt.Errorf("server variation changed concurrently after an uncertain write: %w", writeErr) + } +} + +// withExplicitDetach sends an empty reference list when the local variation +// has no tools or skills but LaunchDarkly has some. The API treats an absent +// list as unchanged. +func withExplicitDetach(local syncdomain.Variation, server *syncdomain.Variation) syncdomain.Variation { + if server == nil { + return local + } + if local.Tools == nil && len(server.Tools) != 0 { + local.Tools = []syncdomain.AttachmentRef{} + } + if local.Skills == nil && len(server.Skills) != 0 { + local.Skills = []syncdomain.AttachmentRef{} + } + return local +} + +// applyLocalChange writes the LaunchDarkly state of one variation to its +// local file, or deletes the file. +func applyLocalChange(store synclocal.Store, resource PlannedResource) error { + configKey, variationKey, err := resource.ID.VariationKeys() + if err != nil { + return err + } + + switch resource.Action { + case ActionUpdateLocal: + _, err := store.ReplaceVariations([]synclocal.VariationReplacement{{ + ProjectKey: resource.ID.ProjectKey, + ConfigKey: configKey, + CreateIfMissing: resource.Local == nil, + Variation: *resource.Server, + }}) + return err + case ActionDeleteLocal: + _, err := store.DeleteVariations([]synclocal.VariationDeletion{{ + ProjectKey: resource.ID.ProjectKey, ConfigKey: configKey, VariationKey: variationKey, + }}) + return err default: - return fmt.Errorf("server variation changed concurrently after an uncertain write: %w", mutationErr) + return fmt.Errorf("action %q does not change a local file", resource.Action) } } -// verifyLocalResult confirms that a local file mutation produced the selected -// server state. +// verifyLocalResult makes sure that the local file has the state that the +// action wrote. For ActionUpdateLocal, resource.Server is in the form of the +// local file. func verifyLocalResult(repositoryRoot string, resource PlannedResource) error { - actualFingerprint, err := readLocalFingerprint(repositoryRoot, resource.ID) + actual, err := readLocalFingerprint(repositoryRoot, resource.ID) if err != nil { return err } - expectedFingerprint := "" + expected := "" if resource.Action == ActionUpdateLocal { - expectedFingerprint, err = syncdomain.FingerprintVariation( - resource.ID.ProjectKey, - resource.ID.LookupKey, - *resource.Server, - ) + expected, err = syncdomain.FingerprintVariation(resource.ID.ProjectKey, resource.ID.LookupKey, *resource.Server) if err != nil { return err } } - if actualFingerprint != expectedFingerprint { - return fmt.Errorf("local variation did not match the expected state after sync") + if actual != expected { + return errors.New("local variation did not match the expected state after sync") } return nil } -// readLocalFingerprint returns the current fingerprint for one local resource, -// or an empty fingerprint when the resource does not exist. +// readLocalFingerprint returns the fingerprint of one local variation, or an +// empty string when the variation has no local file. func readLocalFingerprint(repositoryRoot string, id ResourceID) (string, error) { - localResources, err := synclocal.CompileWorkspace(repositoryRoot) - if errors.Is(err, synclocal.ErrNoDirectory) { - return "", nil - } + variations, err := compileWorkspace(repositoryRoot) if err != nil { return "", err } - - for _, resource := range localResources { - if resource.Kind != id.Kind || resource.ProjectKey != id.ProjectKey || resource.LookupKey != id.LookupKey { - continue + for _, variation := range variations { + if variation.ID() == id { + return syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, variation.Variation) } - var variation syncdomain.Variation - if err := json.Unmarshal(resource.Payload, &variation); err != nil { - return "", err - } - variation.Attachments = resource.Attachments - return syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, variation) } return "", nil } - -// readServerResource reads one supported resource and hydrates its shared -// dependencies through the plan-local cache. -func readServerResource(client syncapi.Client, attachments *attachmentHydrator, id ResourceID) (ServerResource, error) { - if id.Kind != syncdomain.KindVariation { - return ServerResource{}, fmt.Errorf("unsupported sync resource kind %q", id.Kind) - } - configKey, variationKey, err := splitVariationLookupKey(id.LookupKey) - if err != nil { - return ServerResource{}, err - } - state, err := client.ReadVariation(id.ProjectKey, configKey, variationKey) - if err != nil { - return ServerResource{}, err - } - - resource := ServerResource{ConfigMode: state.ConfigMode} - if state.Exists { - if err := attachments.hydrate(id.ProjectKey, &state.Variation); err != nil { - return ServerResource{}, err - } - resource.Variation = &state.Variation - } - return resource, nil -} - -// splitVariationLookupKey separates a config key from its variation key. -func splitVariationLookupKey(lookupKey string) (string, string, error) { - configKey, variationKey, ok := strings.Cut(lookupKey, "/") - if !ok || configKey == "" || variationKey == "" || strings.Contains(variationKey, "/") { - return "", "", fmt.Errorf("invalid variation lookup key %q", lookupKey) - } - return configKey, variationKey, nil -} - -// changesServer reports whether an action mutates LaunchDarkly. -func changesServer(action Action) bool { - return action == ActionCreateServer || action == ActionUpdateServer || action == ActionArchiveServer -} diff --git a/internal/sync/prompt/execute_test.go b/internal/sync/prompt/execute_test.go index 424ac1f0..dc666735 100644 --- a/internal/sync/prompt/execute_test.go +++ b/internal/sync/prompt/execute_test.go @@ -147,7 +147,8 @@ func TestAttachmentVersioningDoesNotMutateReviewedVariation(t *testing.T) { resolved, err := newAttachmentResolver(client).resolveVariation("project", variation) require.NoError(t, err) - pinned := variationPinnedToLatest(variation) + pinned, err := variation.PinnedToLatest() + require.NoError(t, err) assert.Equal(t, 1, variation.Tools[0].Version) assert.Equal(t, 2, resolved.Tools[0].Version) @@ -180,8 +181,6 @@ func TestApplyResourceChangeRefreshesPinsWithCompleteServerModel(t *testing.T) { server.Name = "Server" server.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}} server.Attachments[0].Version = 2 - localPayload, err := json.Marshal(authored) - require.NoError(t, err) resource := PlannedResource{ ID: testResourceID(), Action: ActionUpdateLocal, @@ -198,7 +197,7 @@ func TestApplyResourceChangeRefreshesPinsWithCompleteServerModel(t *testing.T) { client, newAttachmentResolver(client), resource, - syncdomain.SyncedResource{Payload: localPayload}, + &authored, ) require.NoError(t, err) diff --git a/internal/sync/prompt/local_changes.go b/internal/sync/prompt/local_changes.go deleted file mode 100644 index 142144cb..00000000 --- a/internal/sync/prompt/local_changes.go +++ /dev/null @@ -1,36 +0,0 @@ -package prompt - -import ( - "fmt" - - synclocal "github.com/launchdarkly/ldcli/internal/sync/local" -) - -// applyLocalChange writes the server state represented by one reviewed action. -func applyLocalChange(store synclocal.Store, resource PlannedResource) error { - configKey, variationKey, err := splitVariationLookupKey(resource.ID.LookupKey) - if err != nil { - return err - } - - switch resource.Action { - case ActionUpdateLocal: - variation := variationPinnedToLatest(*resource.Server) - _, err := store.ReplaceVariations([]synclocal.VariationReplacement{{ - ProjectKey: resource.ID.ProjectKey, - ConfigKey: configKey, - CreateIfMissing: resource.Local == nil, - Variation: variation, - }}) - return err - case ActionDeleteLocal: - _, err := store.DeleteVariations([]synclocal.VariationDeletion{{ - ProjectKey: resource.ID.ProjectKey, - ConfigKey: configKey, - VariationKey: variationKey, - }}) - return err - default: - return fmt.Errorf("action %q does not change a local file", resource.Action) - } -} diff --git a/internal/sync/prompt/model_config.go b/internal/sync/prompt/model_config.go index 1bf1a736..7a30256d 100644 --- a/internal/sync/prompt/model_config.go +++ b/internal/sync/prompt/model_config.go @@ -1,8 +1,6 @@ package prompt import ( - "encoding/json" - "fmt" "maps" "reflect" "slices" @@ -13,42 +11,29 @@ import ( type modelConfigGetter func(projectKey, modelConfigKey string) (syncapi.ModelConfig, error) -type modelConfigID struct { - projectKey string - configKey string -} - -// canonicalizeLocalVariationModels returns local resources in the -// server-comparable shape used by planning. An omitted version uses the latest -// versioned model config. +// canonicalizeLocalVariationModels returns a copy of the local variations in +// the form that LaunchDarkly stores. A variation that names a model config +// without a version uses the latest version, and its local model settings +// replace the settings of that model config. The input does not change. func canonicalizeLocalVariationModels( - localFileResources []syncdomain.SyncedResource, + localFiles []syncdomain.SyncedResource, getModelConfig modelConfigGetter, ) ([]syncdomain.SyncedResource, error) { - // Clone the slice so canonical payloads cannot replace local file payloads. - canonicalResources := slices.Clone(localFileResources) + type modelConfigID struct{ projectKey, key string } modelConfigs := make(map[modelConfigID]syncapi.ModelConfig) + canonical := slices.Clone(localFiles) - for index := range canonicalResources { - resource := &canonicalResources[index] - if resource.Kind != syncdomain.KindVariation { - continue - } - - var variation syncdomain.Variation - if err := json.Unmarshal(resource.Payload, &variation); err != nil { - return nil, fmt.Errorf("decode local variation %q: %w", resource.LookupKey, err) - } + for index := range canonical { + variation := &canonical[index].Variation if variation.ModelConfigKey == "" || variation.ModelConfigVersion != 0 { continue } - id := modelConfigID{projectKey: resource.ProjectKey, configKey: variation.ModelConfigKey} + id := modelConfigID{projectKey: canonical[index].ProjectKey, key: variation.ModelConfigKey} modelConfig, ok := modelConfigs[id] if !ok { var err error - modelConfig, err = getModelConfig(id.projectKey, id.configKey) - if err != nil { + if modelConfig, err = getModelConfig(id.projectKey, id.key); err != nil { return nil, err } modelConfigs[id] = modelConfig @@ -57,56 +42,48 @@ func canonicalizeLocalVariationModels( continue } + model := modelConfig.VariationModel() + maps.Copy(model, variation.Model) variation.ModelConfigVersion = modelConfig.Version - canonicalModel := modelConfig.VariationModel() - maps.Copy(canonicalModel, variation.Model) - variation.Model = canonicalModel - payload, err := json.Marshal(variation) - if err != nil { - return nil, fmt.Errorf("encode canonical variation %q: %w", resource.LookupKey, err) - } - resource.Payload = payload + variation.Model = model } - - return canonicalResources, nil + return canonical, nil } -// variationForLocalFile converts server state into local file form. It retains -// model keys from the file and new server values as overrides. -func variationForLocalFile( - serverVariation syncdomain.Variation, - canonicalLocalVariation *syncdomain.Variation, - localFileResource syncdomain.SyncedResource, -) (syncdomain.Variation, error) { - if len(localFileResource.Payload) == 0 || canonicalLocalVariation == nil { - return serverVariation, nil - } - - var localFileVariation syncdomain.Variation - if err := json.Unmarshal(localFileResource.Payload, &localFileVariation); err != nil { - return syncdomain.Variation{}, fmt.Errorf("decode local file variation %q: %w", localFileResource.LookupKey, err) +// variationForLocalFile converts a LaunchDarkly variation to the form of the +// local file. If the local file names a model config without a version, the +// result keeps that form. It removes each model value that the model config +// supplies and that the local file does not set. A new server value stays as +// a local override. +// +// localFile is the variation as the file stores it. canonicalLocal is the same +// variation after canonicalizeLocalVariationModels. Both are nil when the +// variation has no local file. +func variationForLocalFile(server syncdomain.Variation, localFile, canonicalLocal *syncdomain.Variation) syncdomain.Variation { + if localFile == nil || canonicalLocal == nil { + return server } - if localFileVariation.ModelConfigKey == "" || localFileVariation.ModelConfigVersion != 0 { - return serverVariation, nil + if localFile.ModelConfigKey == "" || localFile.ModelConfigVersion != 0 { + return server } - // Preserve an explicit server reference when it differs from the canonical local reference. - if serverVariation.ModelConfigKey != canonicalLocalVariation.ModelConfigKey || - serverVariation.ModelConfigVersion != canonicalLocalVariation.ModelConfigVersion { - return serverVariation, nil + // A server variation that names a different model config, or a different + // version, is an explicit change. Keep it as LaunchDarkly stores it. + if server.ModelConfigKey != canonicalLocal.ModelConfigKey || + server.ModelConfigVersion != canonicalLocal.ModelConfigVersion { + return server } - serverVariation.ModelConfigVersion = 0 - // Clone the server model before removing canonical fields from the local form. - serverVariation.Model = maps.Clone(serverVariation.Model) - for key, value := range serverVariation.Model { - _, definedInLocalFile := localFileVariation.Model[key] - canonicalValue, presentInCanonical := canonicalLocalVariation.Model[key] - if !definedInLocalFile && presentInCanonical && reflect.DeepEqual(value, canonicalValue) { - delete(serverVariation.Model, key) + server.ModelConfigVersion = 0 + server.Model = maps.Clone(server.Model) + for key, value := range server.Model { + _, setInFile := localFile.Model[key] + inherited, fromModelConfig := canonicalLocal.Model[key] + if !setInFile && fromModelConfig && reflect.DeepEqual(value, inherited) { + delete(server.Model, key) } } - if len(serverVariation.Model) == 0 { - serverVariation.Model = nil + if len(server.Model) == 0 { + server.Model = nil } - return serverVariation, nil + return server } diff --git a/internal/sync/prompt/model_config_test.go b/internal/sync/prompt/model_config_test.go index d462811c..5bb6b260 100644 --- a/internal/sync/prompt/model_config_test.go +++ b/internal/sync/prompt/model_config_test.go @@ -1,7 +1,6 @@ package prompt import ( - "encoding/json" "errors" "testing" @@ -50,8 +49,7 @@ func TestCanonicalizeLocalVariationModels(t *testing.T) { require.NoError(t, err) require.Equal(t, []string{"custom-model", "global-model"}, requested) - var canonicalVersioned syncdomain.Variation - require.NoError(t, json.Unmarshal(canonicalLocalResources[0].Payload, &canonicalVersioned)) + canonicalVersioned := canonicalLocalResources[0].Variation assert.Equal(t, 4, canonicalVersioned.ModelConfigVersion) assert.Equal(t, map[string]any{ "modelName": "claude-4", @@ -59,16 +57,13 @@ func TestCanonicalizeLocalVariationModels(t *testing.T) { "custom": map[string]any{"region": "us-east"}, }, canonicalVersioned.Model) - var canonicalUnversioned syncdomain.Variation - require.NoError(t, json.Unmarshal(canonicalLocalResources[1].Payload, &canonicalUnversioned)) + canonicalUnversioned := canonicalLocalResources[1].Variation assert.Equal(t, unversioned, canonicalUnversioned) - var canonicalPinned syncdomain.Variation - require.NoError(t, json.Unmarshal(canonicalLocalResources[2].Payload, &canonicalPinned)) + canonicalPinned := canonicalLocalResources[2].Variation assert.Equal(t, pinned, canonicalPinned) - var localFileVersioned syncdomain.Variation - require.NoError(t, json.Unmarshal(localFileResources[0].Payload, &localFileVersioned)) + localFileVersioned := localFileResources[0].Variation assert.Equal(t, versioned, localFileVersioned) } @@ -88,9 +83,7 @@ func TestCanonicalizeLocalVariationModelsRejectsUnknownConfig(t *testing.T) { func syncedVariation(t *testing.T, projectKey, lookupKey string, variation syncdomain.Variation) syncdomain.SyncedResource { t.Helper() - payload, err := json.Marshal(variation) - require.NoError(t, err) return syncdomain.SyncedResource{ - Kind: syncdomain.KindVariation, ProjectKey: projectKey, LookupKey: lookupKey, Payload: payload, + Kind: syncdomain.KindVariation, ProjectKey: projectKey, LookupKey: lookupKey, Variation: variation, } } diff --git a/internal/sync/prompt/output.go b/internal/sync/prompt/output.go index 23113cc7..7b1fff26 100644 --- a/internal/sync/prompt/output.go +++ b/internal/sync/prompt/output.go @@ -6,10 +6,18 @@ import ( "io" "github.com/charmbracelet/lipgloss" + syncconsole "github.com/launchdarkly/ldcli/internal/sync/console" ) -// OutcomeStatus describes whether a reviewed resource action completed. +// The output kinds that the --output flag selects. +const ( + outputPlaintext = "plaintext" + outputMarkdown = "markdown" + outputJSON = "json" +) + +// OutcomeStatus is the result of one planned action. type OutcomeStatus string const ( @@ -18,7 +26,7 @@ const ( OutcomeSkipped OutcomeStatus = "skipped" ) -// ResourceOutcome records the result of executing one planned resource action. +// ResourceOutcome is the result of the action of one planned resource. type ResourceOutcome struct { ID ResourceID `json:"-"` Action Action `json:"action"` @@ -26,86 +34,71 @@ type ResourceOutcome struct { Error string `json:"error,omitempty"` } -type planResourceOutput struct { - ResourceKind string `json:"resourceKind"` - ProjectKey string `json:"projectKey"` - LookupKey string `json:"lookupKey"` - Action Action `json:"action"` - Error string `json:"error,omitempty"` - Diff variationDiffFields `json:"diff,omitempty"` +// resourceOutput is the identity of a resource in JSON output. +type resourceOutput struct { + ResourceKind string `json:"resourceKind"` + ProjectKey string `json:"projectKey"` + LookupKey string `json:"lookupKey"` } -type outcomeOutput struct { - ResourceKind string `json:"resourceKind"` - ProjectKey string `json:"projectKey"` - LookupKey string `json:"lookupKey"` - Action Action `json:"action"` - Status OutcomeStatus `json:"status"` - Error string `json:"error,omitempty"` +func newResourceOutput(id ResourceID) resourceOutput { + return resourceOutput{ResourceKind: string(id.Kind), ProjectKey: id.ProjectKey, LookupKey: id.LookupKey} } -// writePlanOutput renders the local synchronization plan. +// writePlanOutput writes the plan in the selected output kind. func writePlanOutput(out io.Writer, outputKind string, plan Plan) error { - if outputKind == "" { - outputKind = "plaintext" + outputKind, err := checkOutputKind(outputKind) + if err != nil { + return err } - if outputKind == "json" { - resources := make([]planResourceOutput, 0, len(plan.Resources)) - for _, resource := range plan.Resources { - resources = append(resources, planResourceOutput{ - ResourceKind: string(resource.ID.Kind), - ProjectKey: resource.ID.ProjectKey, - LookupKey: resource.ID.LookupKey, - Action: resource.Action, - Error: resource.Error, - Diff: resource.Diff, - }) - } - return writeJSON(out, map[string]any{"resources": resources}) + if outputKind != outputJSON { + return writePlanReview(out, outputKind, plan, terminalWidth(out)) + } + + type planResourceOutput struct { + resourceOutput + Action Action `json:"action"` + Error string `json:"error,omitempty"` + Diff variationDiffFields `json:"diff,omitempty"` } - if outputKind != "plaintext" && outputKind != "markdown" { - return fmt.Errorf("unsupported output kind %q", outputKind) + resources := make([]planResourceOutput, 0, len(plan.Resources)) + for _, resource := range plan.Resources { + resources = append(resources, planResourceOutput{ + resourceOutput: newResourceOutput(resource.ID), + Action: resource.Action, + Error: resource.Error, + Diff: resource.Diff, + }) } - return writePlanReview(out, outputKind, plan, terminalWidth(out)) + return writeJSON(out, map[string]any{"resources": resources}) } -// writeOutcomeOutput renders execution results, including partial failures. +// writeOutcomeOutput writes the result of each action, which includes failures. func writeOutcomeOutput(out io.Writer, outputKind string, outcomes []ResourceOutcome) error { - if outputKind == "" { - outputKind = "plaintext" + outputKind, err := checkOutputKind(outputKind) + if err != nil { + return err } - if outputKind == "json" { + if outputKind == outputJSON { + type outcomeOutput struct { + resourceOutput + ResourceOutcome + } resources := make([]outcomeOutput, 0, len(outcomes)) for _, outcome := range outcomes { - resources = append(resources, outcomeOutput{ - ResourceKind: string(outcome.ID.Kind), - ProjectKey: outcome.ID.ProjectKey, - LookupKey: outcome.ID.LookupKey, - Action: outcome.Action, - Status: outcome.Status, - Error: outcome.Error, - }) + resources = append(resources, outcomeOutput{resourceOutput: newResourceOutput(outcome.ID), ResourceOutcome: outcome}) } return writeJSON(out, map[string]any{"resources": resources}) } - if outputKind != "plaintext" && outputKind != "markdown" { - return fmt.Errorf("unsupported output kind %q", outputKind) - } console := syncconsole.New(out) - if outputKind == "markdown" { + if outputKind == outputMarkdown { _ = console.Line("## Sync results") } else { _ = console.Line("Sync results:") } for _, outcome := range outcomes { - _ = console.Printf( - "- %s/%s action=%s status=%s\n", - outcome.ID.ProjectKey, - outcome.ID.LookupKey, - outcome.Action, - outcome.Status, - ) + _ = console.Printf("- %s action=%s status=%s\n", outcome.ID, outcome.Action, outcome.Status) if outcome.Error != "" { _ = console.Printf(" Error: %s\n", outcome.Error) } @@ -113,59 +106,59 @@ func writeOutcomeOutput(out io.Writer, outputKind string, outcomes []ResourceOut return nil } -// writePlanReview renders the human review view, including action descriptions, -// validation failures, and any variation diff. +// checkOutputKind returns the output kind. An empty kind is plain text. +func checkOutputKind(outputKind string) (string, error) { + switch outputKind { + case "": + return outputPlaintext, nil + case outputPlaintext, outputMarkdown, outputJSON: + return outputKind, nil + default: + return "", fmt.Errorf("unsupported output kind %q", outputKind) + } +} + +// writePlanReview writes the plan for a person to read. It groups the +// resources by project and config, and shows each action, error, and diff. func writePlanReview(out io.Writer, outputKind string, plan Plan, width int) error { console := syncconsole.New(out) if len(plan.Resources) == 0 { _ = console.Line("No prompt variations are tracked.") return nil } + markdown := outputKind == outputMarkdown - currentProject := "" - currentConfig := "" + currentProject, currentConfig := "", "" for _, resource := range plan.Resources { if resource.ID.ProjectKey != currentProject { if currentProject != "" { _ = console.Line("") } - currentProject = resource.ID.ProjectKey - currentConfig = "" - if outputKind == "markdown" { + currentProject, currentConfig = resource.ID.ProjectKey, "" + if markdown { _ = console.Printf("## Project `%s`\n", currentProject) } else { _ = console.Printf("%s\n", reviewHeading("Project: "+currentProject, width)) } } - if outputKind == "markdown" { - _ = console.Printf( - "\n### Variation `%s`\n\nAction: **%s**\n", - resource.ID.LookupKey, - actionDescription(resource.Action), - ) - } else { - configKey, variationKey, err := splitVariationLookupKey(resource.ID.LookupKey) - if err == nil { - if configKey != currentConfig { - currentConfig = configKey - _ = console.Printf("\n %s\n", reviewHeading("Config: "+configKey, width)) - } - _ = console.Printf( - "\n %s\n Action: %s\n", - reviewHeading("Variation: "+variationKey, width), - actionDescription(resource.Action), - ) - } else { - _ = console.Printf( - "\n %s\n Action: %s\n", - reviewHeading("Variation: "+resource.ID.LookupKey, width), - actionDescription(resource.Action), - ) + action := actionDescription(resource.Action) + configKey, variationKey, err := resource.ID.VariationKeys() + switch { + case markdown: + _ = console.Printf("\n### Variation `%s`\n\nAction: **%s**\n", resource.ID.LookupKey, action) + case err != nil: + _ = console.Printf("\n %s\n Action: %s\n", reviewHeading("Variation: "+resource.ID.LookupKey, width), action) + default: + if configKey != currentConfig { + currentConfig = configKey + _ = console.Printf("\n %s\n", reviewHeading("Config: "+configKey, width)) } + _ = console.Printf("\n %s\n Action: %s\n", reviewHeading("Variation: "+variationKey, width), action) } + if resource.Error != "" { - if outputKind == "markdown" { + if markdown { _ = console.Printf("Error: %s\n", resource.Error) } else { _ = console.Printf(" Error: %s\n", resource.Error) @@ -182,6 +175,7 @@ func writePlanReview(out io.Writer, outputKind string, plan Plan, width int) err return nil } +// reviewHeading colors a heading when the output is a terminal. func reviewHeading(value string, width int) string { if width <= 0 { return value @@ -189,7 +183,7 @@ func reviewHeading(value string, width int) string { return lipgloss.NewStyle().Foreground(lipgloss.Color("67")).Bold(true).Render(value) } -// actionDescription translates internal reconciliation actions into user-facing language. +// actionDescription describes an action for the user. func actionDescription(action Action) string { switch action { case ActionInSync: @@ -217,14 +211,16 @@ func actionDescription(action Action) string { } } +// variationDiffPresentation is the labels and the direction of a diff. type variationDiffPresentation struct { beforeLabel string afterLabel string missingAfter string - reverse bool + // reverse shows the local file as "before", because the local file changes. + reverse bool } -// diffPresentation chooses labels, direction, and absence text for an action. +// diffPresentation returns the labels that describe the change of an action. func diffPresentation(action Action) variationDiffPresentation { switch action { case ActionUpdateLocal, ActionDeleteLocal: @@ -240,7 +236,7 @@ func diffPresentation(action Action) variationDiffPresentation { } } -// writeJSON emits indented, newline-terminated JSON for machine-readable output. +// writeJSON writes indented JSON with a final newline. func writeJSON(out io.Writer, value any) error { encoder := json.NewEncoder(out) encoder.SetIndent("", " ") diff --git a/internal/sync/prompt/plan.go b/internal/sync/prompt/plan.go index 75764c17..79c47f83 100644 --- a/internal/sync/prompt/plan.go +++ b/internal/sync/prompt/plan.go @@ -1,7 +1,6 @@ package prompt import ( - "encoding/json" "fmt" "slices" @@ -9,7 +8,7 @@ import ( syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" ) -// Action describes the one change needed to reconcile a resource. +// Action is the one change that reconciles a resource. type Action string const ( @@ -25,285 +24,163 @@ const ( ActionError Action = "error" ) -// ResourceID is the shared identity of a synchronized resource. +// changesServer reports whether the action writes to LaunchDarkly. +func (action Action) changesServer() bool { + return action == ActionCreateServer || action == ActionUpdateServer || action == ActionArchiveServer +} + +// changesLocal reports whether the action writes a local file. +func (action Action) changesLocal() bool { + return action == ActionUpdateLocal || action == ActionDeleteLocal +} + +// ResourceID is the identity of a synchronized resource. type ResourceID = syncdomain.ResourceID -// ServerResource contains a variation and the mode owned by its parent config. -// The variation APIs cannot change that mode. +// ServerResource is a variation in LaunchDarkly and the mode of its config. +// Variation is nil when the variation does not exist. The config owns the +// mode, and the variation API cannot change it. type ServerResource struct { Variation *syncdomain.Variation ConfigMode syncdomain.VariationMode } -// PlannedResource contains the compared local/server state and the action -// selected for one resource. +// PlannedResource is the local and the server state of one resource, and the +// action that reconciles them. type PlannedResource struct { - ID ResourceID - Action Action - Upsert bool - BaselineFingerprint string - LocalFingerprint string - ServerFingerprint string - ServerMode syncdomain.VariationMode - Local *syncdomain.Variation - Server *syncdomain.Variation + ID ResourceID + Action Action + Upsert bool + BaselineFingerprint string + LocalFingerprint string + ServerFingerprint string + ServerMode syncdomain.VariationMode + Local *syncdomain.Variation + Server *syncdomain.Variation + // ServerHasStaleAttachmentPins is true when LaunchDarkly pins an older + // version of a tool or skill than its latest version. ServerHasStaleAttachmentPins bool Diff variationDiffFields Error string - changedAttachments []attachmentID + // changedAttachments are the tools and skills whose content differs + // between the local file and LaunchDarkly. + changedAttachments []ResourceID } -// Plan contains sync decisions in deterministic resource order. +// Plan is the sync decision for each resource, in identity order. type Plan struct { Resources []PlannedResource } -// BuildPlan compares the committed baseline with current local and server -// state. Server must contain an entry, with a nil Variation for absence, for -// every candidate resource. +// BuildPlan compares the baseline with the local and the server state. The +// server map must have an entry for each resource in local and in baseline. func BuildPlan(baseline syncmanifest.Manifest, local []syncdomain.SyncedResource, server map[ResourceID]ServerResource) Plan { localByID := make(map[ResourceID]syncdomain.SyncedResource, len(local)) - resourceIDs := make(map[ResourceID]struct{}, len(local)+len(baseline.Resources)) - for _, resource := range local { - id := ResourceID{Kind: resource.Kind, ProjectKey: resource.ProjectKey, LookupKey: resource.LookupKey} - localByID[id] = resource - resourceIDs[id] = struct{}{} + localByID[resource.ID()] = resource } - baselineByID := make(map[ResourceID]string, len(baseline.Resources)) for _, resource := range baseline.Resources { - if resource.ResourceKind != syncdomain.KindVariation { - continue + if resource.ResourceKind == syncdomain.KindVariation { + baselineByID[resource.ID()] = resource.Fingerprint } - id := resource.ID() - baselineByID[id] = resource.Fingerprint - resourceIDs[id] = struct{}{} } - orderedIDs := make([]ResourceID, 0, len(resourceIDs)) - for id := range resourceIDs { - orderedIDs = append(orderedIDs, id) + ids := make([]ResourceID, 0, len(localByID)+len(baselineByID)) + for id := range localByID { + ids = append(ids, id) + } + for id := range baselineByID { + if _, isLocal := localByID[id]; !isLocal { + ids = append(ids, id) + } } - slices.SortFunc(orderedIDs, syncdomain.CompareResourceIDs) + slices.SortFunc(ids, syncdomain.CompareResourceIDs) - plan := Plan{Resources: make([]PlannedResource, 0, len(orderedIDs))} - for _, id := range orderedIDs { - localResource, localExists := localByID[id] + plan := Plan{Resources: make([]PlannedResource, 0, len(ids))} + for _, id := range ids { + localResource, isLocal := localByID[id] + var localState *syncdomain.SyncedResource + if isLocal { + localState = &localResource + } baselineFingerprint, tracked := baselineByID[id] - plan.Resources = append(plan.Resources, buildPlannedResource( - id, localResource, localExists, server[id], baselineFingerprint, tracked, - )) + plan.Resources = append(plan.Resources, planResource(id, localState, server[id], baselineFingerprint, tracked)) } - return plan } -// buildPlannedResource validates one local/server pair before choosing its action. -func buildPlannedResource( +// planResource fingerprints both sides of one resource and chooses its action. +// local is nil when the resource has no local file. +func planResource( id ResourceID, - localResource syncdomain.SyncedResource, - localExists bool, - serverResource ServerResource, + local *syncdomain.SyncedResource, + server ServerResource, baselineFingerprint string, tracked bool, ) PlannedResource { resource := PlannedResource{ - ID: id, BaselineFingerprint: baselineFingerprint, Server: serverResource.Variation, ServerMode: serverResource.ConfigMode, + ID: id, BaselineFingerprint: baselineFingerprint, Server: server.Variation, ServerMode: server.ConfigMode, } - - if localExists { - resource.Upsert = localResource.Upsert - var variation syncdomain.Variation - if err := json.Unmarshal(localResource.Payload, &variation); err != nil { - resource.Action, resource.Error = ActionError, fmt.Sprintf("decode local variation: %s", err) - return resource - } - variation.Attachments = localResource.Attachments - if variation.Mode != serverResource.ConfigMode { - resource.Action = ActionError - resource.Error = fmt.Sprintf("local mode %q does not match config mode %q", variation.Mode, serverResource.ConfigMode) - return resource - } - resource.Local = &variation + fail := func(message string) PlannedResource { + resource.Action, resource.Error = ActionError, message + return resource } var err error - if resource.Local != nil { - resource.LocalFingerprint, err = syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, *resource.Local) - if err != nil { - resource.Action, resource.Error = ActionError, err.Error() - return resource + if local != nil { + resource.Upsert = local.Upsert + variation := local.Variation + if variation.Mode != server.ConfigMode { + return fail(fmt.Sprintf("local mode %q does not match config mode %q", variation.Mode, server.ConfigMode)) + } + resource.Local = &variation + if resource.LocalFingerprint, err = syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, variation); err != nil { + return fail(err.Error()) } } - if resource.Server != nil { - resource.ServerFingerprint, err = syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, *resource.Server) - if err != nil { - resource.Action, resource.Error = ActionError, fmt.Sprintf("invalid server variation: %s", err) - return resource + if server.Variation != nil { + if resource.ServerFingerprint, err = syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, *server.Variation); err != nil { + return fail(fmt.Sprintf("invalid server variation: %s", err)) } } resource.Action = chooseAction(tracked, resource) + if resource.Action == ActionError { + resource.Error = "variation does not exist in LaunchDarkly; set upsert: true to create it" + } + // The fingerprints do not include versions. If LaunchDarkly pins an older + // attachment version, sync updates the pin even when the content matches. currentPins, latestPins, stalePins := attachmentPinDiff(resource.Server) resource.ServerHasStaleAttachmentPins = stalePins - if resource.ServerHasStaleAttachmentPins && - (resource.Action == ActionInSync || resource.Action == ActionUpdateManifest) { + if stalePins && (resource.Action == ActionInSync || resource.Action == ActionUpdateManifest) { resource.Action = ActionUpdateServer } - if resource.Action == ActionError { - resource.Error = "variation does not exist in LaunchDarkly; set upsert: true to create it" - } - // Diffs explain semantic changes. API-added defaults can make the raw JSON - // differ even when the canonical fingerprints—and therefore behavior—match. + + // The diff shows only changes in behavior. The raw JSON can differ when + // the fingerprints match, for example because the API adds defaults. if resource.LocalFingerprint != resource.ServerFingerprint { resource.Diff = variationDiff(resource.Server, resource.Local) } - resource.changedAttachments = changedAttachmentIDs(resource) - if resource.Action != ActionConflict && stalePins { + if stalePins && resource.Action != ActionConflict { if resource.Diff == nil { resource.Diff = variationDiffFields{} } resource.Diff["attachment versions"] = variationFieldDiff{Before: currentPins, After: latestPins} } + resource.changedAttachments = changedAttachmentIDs(resource) return resource } -// changedAttachmentIDs returns shared dependencies whose canonical local and -// server content differs for this variation. -func changedAttachmentIDs(resource PlannedResource) []attachmentID { - var conflicts []attachmentID - for _, kind := range []syncdomain.AttachmentKind{syncdomain.AttachmentTool, syncdomain.AttachmentSkill} { - for _, key := range changedAttachmentKeys(resource.Server, resource.Local, kind) { - conflicts = append(conflicts, attachmentID{ - projectKey: resource.ID.ProjectKey, - kind: kind, - key: key, - }) - } - } - return conflicts -} - -// changedAttachmentKeys compares one attachment kind by stable key. -func changedAttachmentKeys(before, after *syncdomain.Variation, kind syncdomain.AttachmentKind) []string { - beforeByKey := attachmentsByKey(before, kind) - afterByKey := attachmentsByKey(after, kind) - keys := make([]string, 0, len(beforeByKey)+len(afterByKey)) - - for key, attachment := range beforeByKey { - other, exists := afterByKey[key] - if !exists || !sameAttachmentContent(attachment, other) { - keys = append(keys, key) - } - } - for key := range afterByKey { - if _, exists := beforeByKey[key]; !exists { - keys = append(keys, key) - } - } - slices.Sort(keys) - return keys -} - -// attachmentsByKey indexes hydrated canonical content for comparison. -func attachmentsByKey(variation *syncdomain.Variation, kind syncdomain.AttachmentKind) map[string]syncdomain.Attachment { - attachments := map[string]syncdomain.Attachment{} - if variation == nil { - return attachments - } - for _, attachment := range variation.Attachments { - if attachment.Kind == kind { - attachments[attachment.Key()] = attachment - } - } - return attachments -} - -// attachmentPinDiff reports server references that do not use the latest -// hydrated version. -func attachmentPinDiff(variation *syncdomain.Variation) (json.RawMessage, json.RawMessage, bool) { - if variation == nil { - return nil, nil, false - } - current := map[string]map[string]int{"tools": {}, "skills": {}} - latest := map[string]map[string]int{"tools": {}, "skills": {}} - for _, ref := range variation.Tools { - if attachment, ok := variation.Attachment(syncdomain.AttachmentTool, ref.Key); ok && - attachment.Version != ref.Version { - current["tools"][ref.Key] = ref.Version - latest["tools"][ref.Key] = attachment.Version - } - } - for _, ref := range variation.Skills { - if attachment, ok := variation.Attachment(syncdomain.AttachmentSkill, ref.Key); ok && - attachment.Version != ref.Version { - current["skills"][ref.Key] = ref.Version - latest["skills"][ref.Key] = attachment.Version - } - } - if len(current["tools"]) == 0 && len(current["skills"]) == 0 { - return nil, nil, false - } - currentJSON, _ := json.Marshal(current) - latestJSON, _ := json.Marshal(latest) - return currentJSON, latestJSON, true -} - -// RequiresConfirmation reports whether the plan changes local or server -// resources. Manifest-only bookkeeping is safe to perform without prompting. -func (plan Plan) RequiresConfirmation() bool { - for _, resource := range plan.Resources { - switch resource.Action { - case ActionCreateServer, ActionUpdateServer, ActionArchiveServer, ActionUpdateLocal, ActionDeleteLocal: - return true - } - } - return false -} - -// HasDestructiveActions reports whether applying the plan would remove a local -// resource or archive one in LaunchDarkly. -func (plan Plan) HasDestructiveActions() bool { - for _, resource := range plan.Resources { - if resource.Action == ActionArchiveServer || resource.Action == ActionDeleteLocal { - return true - } - } - return false -} - -// BlockingError returns a readable error for conflicts or invalid resources. -func (plan Plan) BlockingError() error { - for _, resource := range plan.Resources { - switch resource.Action { - case ActionConflict: - return fmt.Errorf("cannot sync conflicted resource %s/%s", resource.ID.ProjectKey, resource.ID.LookupKey) - case ActionError: - return fmt.Errorf("cannot sync %s/%s: %s", resource.ID.ProjectKey, resource.ID.LookupKey, resource.Error) - } - } - return nil -} - -// HasChanges reports whether synchronization has work to perform. -func (plan Plan) HasChanges() bool { - for _, resource := range plan.Resources { - if resource.Action != ActionInSync { - return true - } - } - return false -} - -// chooseAction compares local and server fingerprints with the manifest -// baseline to determine which side changed. +// chooseAction compares each fingerprint with the baseline to find which side +// changed. It returns ActionError for a new local variation without upsert. func chooseAction(tracked bool, resource PlannedResource) Action { localExists := resource.Local != nil serverExists := resource.Server != nil - // Without a baseline there is no direction to infer. Adopt identical state, - // honor explicit local upsert, and require a choice for divergent content. + // Without a baseline, sync cannot tell which side changed. It adopts equal + // state, creates a new local variation that has upsert, and reports a + // conflict for different content. if !tracked { switch { case localExists && serverExists && resource.LocalFingerprint == resource.ServerFingerprint: @@ -322,106 +199,71 @@ func chooseAction(tracked bool, resource PlannedResource) Action { localUnchanged := resource.LocalFingerprint == resource.BaselineFingerprint serverUnchanged := resource.ServerFingerprint == resource.BaselineFingerprint switch { - // Neither side moved from the common ancestor. case localUnchanged && serverUnchanged: return ActionInSync - // Both sides independently reached the same state, including deletion. case resource.LocalFingerprint == resource.ServerFingerprint: + // Both sides changed to the same state, which can be a deletion. if !localExists && !serverExists { return ActionRemoveManifest } return ActionUpdateManifest - // Only local moved, so local is authoritative for this run. - case !localUnchanged && serverUnchanged: - if !localExists { + case serverUnchanged: + // Only the local file changed, so LaunchDarkly follows it. + switch { + case !localExists: return ActionArchiveServer - } - if serverExists { + case serverExists: return ActionUpdateServer + default: + return ActionConflict } - return ActionConflict - // Only LaunchDarkly moved, so pull or mirror its deletion locally. - case localUnchanged && !serverUnchanged: - if !serverExists { + case localUnchanged: + // Only LaunchDarkly changed, so the local file follows it. + switch { + case !serverExists: return ActionDeleteLocal - } - if localExists { + case localExists: return ActionUpdateLocal + default: + return ActionConflict } - return ActionConflict - // Both sides moved to different states. default: return ActionConflict } } -// variationDiff builds the structured diff rendered during plan review. -func variationDiff(before, after *syncdomain.Variation) variationDiffFields { - if before == nil && after == nil { - return nil - } - - fields := variationDiffFields{} - beforeJSON, _ := json.Marshal(variationForDiff(before)) - afterJSON, _ := json.Marshal(variationForDiff(after)) - if before == nil { - beforeJSON = nil - } - if after == nil { - afterJSON = nil - } - if string(beforeJSON) != string(afterJSON) { - fields["variation"] = variationFieldDiff{Before: beforeJSON, After: afterJSON} - } - - beforeTools, beforeSkills := attachmentDiffValues(before) - afterTools, afterSkills := attachmentDiffValues(after) - if string(beforeTools) != string(afterTools) { - fields["tools"] = variationFieldDiff{Before: beforeTools, After: afterTools} - } - if string(beforeSkills) != string(afterSkills) { - fields["skills"] = variationFieldDiff{Before: beforeSkills, After: afterSkills} - } - if len(fields) == 0 { - return nil - } - return fields +// RequiresConfirmation reports whether the plan changes a local file or +// LaunchDarkly. A change to the manifest only does not need a confirmation. +func (plan Plan) RequiresConfirmation() bool { + return slices.ContainsFunc(plan.Resources, func(resource PlannedResource) bool { + return resource.Action.changesServer() || resource.Action.changesLocal() + }) } -func variationForDiff(variation *syncdomain.Variation) *syncdomain.Variation { - if variation == nil { - return nil - } - normalized := *variation - // Attachments have their own content-aware diff. Omitting their references - // here prevents the same attach or detach operation appearing twice. - normalized.Tools = nil - normalized.Skills = nil - return &normalized +// HasDestructiveActions reports whether the plan deletes a local file or +// archives a variation in LaunchDarkly. +func (plan Plan) HasDestructiveActions() bool { + return slices.ContainsFunc(plan.Resources, func(resource PlannedResource) bool { + return resource.Action == ActionArchiveServer || resource.Action == ActionDeleteLocal + }) } -func attachmentDiffValues(variation *syncdomain.Variation) (json.RawMessage, json.RawMessage) { - if variation == nil { - return nil, nil - } +// HasChanges reports whether the plan has work to do. +func (plan Plan) HasChanges() bool { + return slices.ContainsFunc(plan.Resources, func(resource PlannedResource) bool { + return resource.Action != ActionInSync + }) +} - var tools []syncdomain.Tool - var skills []syncdomain.Skill - for _, attachment := range variation.Attachments { - canonical := syncdomain.CanonicalAttachment(attachment) - if canonical.Tool != nil { - tools = append(tools, *canonical.Tool) - } else if canonical.Skill != nil { - skills = append(skills, *canonical.Skill) +// BlockingError returns an error for the first conflict or invalid resource. +func (plan Plan) BlockingError() error { + for _, resource := range plan.Resources { + switch resource.Action { + case ActionConflict: + return fmt.Errorf("cannot sync conflicted resource %s", resource.ID) + case ActionError: + return fmt.Errorf("cannot sync %s: %s", resource.ID, resource.Error) } } - - var toolJSON, skillJSON json.RawMessage - if len(tools) != 0 { - toolJSON, _ = json.Marshal(tools) - } - if len(skills) != 0 { - skillJSON, _ = json.Marshal(skills) - } - return toolJSON, skillJSON + return nil } diff --git a/internal/sync/prompt/plan_test.go b/internal/sync/prompt/plan_test.go index ef94d7ed..471a0503 100644 --- a/internal/sync/prompt/plan_test.go +++ b/internal/sync/prompt/plan_test.go @@ -1,7 +1,6 @@ package prompt import ( - "encoding/json" "testing" "github.com/stretchr/testify/require" @@ -171,13 +170,12 @@ func localResources(variation *syncdomain.Variation, upsert bool) []syncdomain.S if variation == nil { return nil } - payload, _ := json.Marshal(variation) id := testResourceID() return []syncdomain.SyncedResource{{ Kind: id.Kind, ProjectKey: id.ProjectKey, LookupKey: id.LookupKey, - Payload: payload, + Variation: *variation, Upsert: upsert, }} } diff --git a/internal/sync/prompt/render_diff.go b/internal/sync/prompt/render_diff.go new file mode 100644 index 00000000..2a19e9f8 --- /dev/null +++ b/internal/sync/prompt/render_diff.go @@ -0,0 +1,602 @@ +package prompt + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "maps" + "os" + "reflect" + "slices" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/pmezard/go-difflib/difflib" + "golang.org/x/term" +) + +// renderedDiffSection is one titled block of a rendered diff. +type renderedDiffSection struct { + title string + change string + before string + after string +} + +const ( + diffSectionIndent = 6 + diffBodyIndent = 8 +) + +// renderVariationDiff formats structured field changes as terminal or Markdown +// unified diffs, choosing side-by-side output when the terminal is wide enough. +func renderVariationDiff(fields variationDiffFields, outputKind string, width int, presentation variationDiffPresentation) (string, error) { + keys := make([]string, 0, len(fields)) + for key := range fields { + keys = append(keys, key) + } + slices.Sort(keys) + + var rendered strings.Builder + for _, key := range keys { + diff := fields[key] + if presentation.reverse { + diff.Before, diff.After = diff.After, diff.Before + } + sections, err := diffSections(key, diff, presentation) + if err != nil { + return "", err + } + for _, section := range sections { + diffLines, err := unifiedDiffLines(section.before, section.after, presentation.beforeLabel, presentation.afterLabel) + if err != nil { + return "", err + } + if key == "tools" || key == "skills" { + diffLines = slices.DeleteFunc(diffLines, func(line string) bool { + return strings.HasPrefix(line, "@@") + }) + } + if outputKind == "markdown" { + _, _ = fmt.Fprintf(&rendered, "\n#### %s (%s)\n\n", section.title, section.change) + content := strings.Join(diffLines, "\n") + fence := markdownCodeFence(content) + _, _ = fmt.Fprintf(&rendered, "%sdiff\n%s\n%s\n", fence, content, fence) + continue + } + sectionTitle := fmt.Sprintf("%s (%s)", section.title, section.change) + if width > 0 { + sectionTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("67")).Bold(true).Render(sectionTitle) + } + _, _ = fmt.Fprintf(&rendered, "\n%s%s\n\n", strings.Repeat(" ", diffSectionIndent), sectionTitle) + if width >= 100 { + rendered.WriteString(renderSideBySideUnifiedDiff(diffLines, width)) + } else { + rendered.WriteString(renderUnifiedDiff(diffLines, width > 0)) + } + } + } + return rendered.String(), nil +} + +// markdownCodeFence returns a fence that is longer than every run of +// backticks in content, so that the content cannot close the fence. +func markdownCodeFence(content string) string { + longestRun, currentRun := 0, 0 + for _, character := range content { + if character != '`' { + currentRun = 0 + continue + } + currentRun++ + longestRun = max(longestRun, currentRun) + } + return strings.Repeat("`", max(3, longestRun+1)) +} + +func diffSections(field string, diff variationFieldDiff, presentation variationDiffPresentation) ([]renderedDiffSection, error) { + switch field { + case "tools": + return attachmentDiffSections[syncdomain.Tool]("Tool", diff, func(tool syncdomain.Tool) string { return tool.Key }, formatToolDetails) + case "skills": + return attachmentDiffSections[syncdomain.Skill]( + "Skill", + diff, + func(skill syncdomain.Skill) string { return skill.Key }, + formatSkillDetails, + ) + } + + before, err := formatDiffValue(diff.Before, presentation.beforeLabel, "") + if err != nil { + return nil, err + } + after, err := formatDiffValue(diff.After, presentation.afterLabel, presentation.missingAfter) + if err != nil { + return nil, err + } + return []renderedDiffSection{{ + title: diffFieldTitle(field), change: diffChangeKind(diff.Before, diff.After), + before: before, after: after, + }}, nil +} + +func attachmentDiffSections[T any]( + kind string, + diff variationFieldDiff, + key func(T) string, + format func(T) string, +) ([]renderedDiffSection, error) { + before, err := decodeAttachmentDiffItems[T](diff.Before) + if err != nil { + return nil, fmt.Errorf("format %s diff: %w", strings.ToLower(kind), err) + } + after, err := decodeAttachmentDiffItems[T](diff.After) + if err != nil { + return nil, fmt.Errorf("format %s diff: %w", strings.ToLower(kind), err) + } + + beforeByKey := make(map[string]T, len(before)) + afterByKey := make(map[string]T, len(after)) + keys := make([]string, 0, len(before)+len(after)) + for _, item := range before { + beforeByKey[key(item)] = item + keys = append(keys, key(item)) + } + for _, item := range after { + afterByKey[key(item)] = item + if _, exists := beforeByKey[key(item)]; !exists { + keys = append(keys, key(item)) + } + } + slices.Sort(keys) + + sections := make([]renderedDiffSection, 0, len(keys)) + for _, itemKey := range keys { + beforeItem, beforeExists := beforeByKey[itemKey] + afterItem, afterExists := afterByKey[itemKey] + if beforeExists && afterExists && reflect.DeepEqual(beforeItem, afterItem) { + continue + } + + beforeValue, afterValue := "(not attached)", "(not attached)" + if beforeExists { + beforeValue = format(beforeItem) + } + if afterExists { + afterValue = format(afterItem) + } + sections = append(sections, renderedDiffSection{ + title: fmt.Sprintf("%s %q", kind, itemKey), + change: attachmentChangeKind(beforeExists, afterExists), + before: beforeValue, + after: afterValue, + }) + } + return sections, nil +} + +func decodeAttachmentDiffItems[T any](value json.RawMessage) ([]T, error) { + if len(value) == 0 || bytes.Equal(value, []byte("null")) { + return nil, nil + } + var items []T + if err := json.Unmarshal(value, &items); err != nil { + return nil, err + } + return items, nil +} + +func diffChangeKind(before, after json.RawMessage) string { + switch { + case len(before) == 0: + return "added" + case len(after) == 0: + return "removed" + default: + return "changed" + } +} + +func attachmentChangeKind(before, after bool) string { + switch { + case !before: + return "added" + case !after: + return "removed" + default: + return "changed" + } +} + +// unifiedDiffLines delegates line-level comparison to go-difflib while keeping +// labels and context consistent across output modes. +func unifiedDiffLines(before, after, beforeLabel, afterLabel string) ([]string, error) { + diff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: diffInputLines(before), + B: diffInputLines(after), + FromFile: beforeLabel, + ToFile: afterLabel, + Context: 1, + }) + if err != nil { + return nil, fmt.Errorf("build variation diff: %w", err) + } + return strings.Split(strings.TrimSuffix(diff, "\n"), "\n"), nil +} + +func diffFieldTitle(field string) string { + switch field { + case "variation": + return "Variation content" + case "attachment versions": + return "Attachment versions for this variation" + default: + return field + } +} + +// diffInputLines gives every logical line the terminator expected by difflib. +func diffInputLines(value string) []string { + lines := strings.Split(value, "\n") + for index := range lines { + lines[index] += "\n" + } + return lines +} + +// renderUnifiedDiff renders a conventional single-column diff and highlights +// paired replacements more precisely than independent added/removed lines. +func renderUnifiedDiff(lines []string, color bool) string { + var rendered strings.Builder + for index := 0; index < len(lines); { + change := scanDiffChange(lines, index) + if len(change.removed) != 0 && len(change.added) != 0 { + // Clone scanned lines before styling so later rendering still sees + // the original diff text. + styledRemoved := append([]string(nil), change.removed...) + styledAdded := append([]string(nil), change.added...) + pairs := min(len(change.removed), len(change.added)) + for pair := 0; pair < pairs; pair++ { + if color { + styledRemoved[pair], styledAdded[pair] = renderChangedLinePair( + change.removed[pair], + change.added[pair], + ) + } + } + for index := pairs; index < len(styledRemoved); index++ { + styledRemoved[index] = styleDiffLine(styledRemoved[index], color) + } + for index := pairs; index < len(styledAdded); index++ { + styledAdded[index] = styleDiffLine(styledAdded[index], color) + } + for _, line := range append(styledRemoved, styledAdded...) { + _, _ = fmt.Fprintf(&rendered, "%s%s\n", strings.Repeat(" ", diffBodyIndent), line) + } + index = change.next + continue + } + _, _ = fmt.Fprintf( + &rendered, + "%s%s\n", + strings.Repeat(" ", diffBodyIndent), + styleDiffLine(lines[index], color), + ) + index++ + } + return rendered.String() +} + +// renderSideBySideUnifiedDiff aligns removed and added lines into equal-width +// columns while retaining unified-diff headers and hunks. +func renderSideBySideUnifiedDiff(lines []string, width int) string { + if len(lines) < 2 { + return renderUnifiedDiff(lines, true) + } + + const ( + columnGap = 2 + ) + columnWidth := (width - diffBodyIndent - columnGap) / 2 + cellStyle := lipgloss.NewStyle().Width(columnWidth) + + var rendered strings.Builder + writeRow := func(before, after string) { + before = ansi.Wordwrap(before, columnWidth, ",:") + after = ansi.Wordwrap(after, columnWidth, ",:") + row := lipgloss.JoinHorizontal( + lipgloss.Top, + cellStyle.Render(before), + strings.Repeat(" ", columnGap), + cellStyle.Render(after), + ) + _, _ = fmt.Fprintf( + &rendered, + "%s\n", + indentBlock(row, diffBodyIndent), + ) + } + + writeRow(styleDiffLine(lines[0], true), styleDiffLine(lines[1], true)) + for index := 2; index < len(lines); { + if strings.HasPrefix(lines[index], "@@") { + _, _ = fmt.Fprintf( + &rendered, + "%s%s\n", + strings.Repeat(" ", diffBodyIndent), + styleDiffLine(lines[index], true), + ) + index++ + continue + } + + change := scanDiffChange(lines, index) + if len(change.removed) != 0 || len(change.added) != 0 { + for pair := 0; pair < max(len(change.removed), len(change.added)); pair++ { + var beforeLine, afterLine string + switch { + case pair < len(change.removed) && pair < len(change.added): + beforeLine, afterLine = renderChangedLinePair( + change.removed[pair], + change.added[pair], + ) + case pair < len(change.removed): + beforeLine = styleDiffLine(change.removed[pair], true) + default: + afterLine = styleDiffLine(change.added[pair], true) + } + writeRow(beforeLine, afterLine) + } + index = change.next + continue + } + + context := styleDiffLine(lines[index], true) + writeRow(context, context) + index++ + } + return rendered.String() +} + +type diffChange struct { + removed []string + added []string + next int +} + +// scanDiffChange groups adjacent removed and added lines into one replacement block. +func scanDiffChange(lines []string, start int) diffChange { + removedEnd := start + for removedEnd < len(lines) && isRemovedDiffLine(lines[removedEnd]) { + removedEnd++ + } + + addedEnd := removedEnd + for addedEnd < len(lines) && isAddedDiffLine(lines[addedEnd]) { + addedEnd++ + } + + return diffChange{ + removed: lines[start:removedEnd], + added: lines[removedEnd:addedEnd], + next: addedEnd, + } +} + +// indentBlock applies the same left margin to every rendered line. +func indentBlock(value string, spaces int) string { + prefix := strings.Repeat(" ", spaces) + return prefix + strings.ReplaceAll(value, "\n", "\n"+prefix) +} + +// isRemovedDiffLine distinguishes content removals from the --- file header. +func isRemovedDiffLine(line string) bool { + return strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") +} + +// isAddedDiffLine distinguishes content additions from the +++ file header. +func isAddedDiffLine(line string) bool { + return strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") +} + +// styleDiffLine applies semantic colors to headers, hunks, context, and changes. +func styleDiffLine(line string, color bool) string { + if !color { + return line + } + switch { + case strings.HasPrefix(line, "---"), isRemovedDiffLine(line): + return lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(line) + case strings.HasPrefix(line, "+++"), isAddedDiffLine(line): + return lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(line) + case strings.HasPrefix(line, "@@"): + return lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Render(line) + default: + return lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(line) + } +} + +// renderChangedLinePair highlights only the changed span within paired lines. +func renderChangedLinePair(before, after string) (string, string) { + if !isRemovedDiffLine(before) || !isAddedDiffLine(after) { + return styleDiffLine(before, true), styleDiffLine(after, true) + } + + prefix, removed, added, suffix := changedParts(before[1:], after[1:]) + removedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + addedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + removedHighlight := removedStyle. + Background(lipgloss.Color("52")). + Bold(true) + addedHighlight := addedStyle. + Background(lipgloss.Color("22")). + Bold(true) + + return removedStyle.Render("-"+prefix) + + removedHighlight.Render(removed) + + removedStyle.Render(suffix), + addedStyle.Render("+"+prefix) + + addedHighlight.Render(added) + + addedStyle.Render(suffix) +} + +// changedParts separates two lines into their shared prefix, changed middle, +// and shared suffix using runes rather than bytes. +func changedParts(before, after string) (prefix, removed, added, suffix string) { + beforeRunes := []rune(before) + afterRunes := []rune(after) + prefixLength := 0 + for prefixLength < min(len(beforeRunes), len(afterRunes)) && + beforeRunes[prefixLength] == afterRunes[prefixLength] { + prefixLength++ + } + + suffixLength := 0 + for suffixLength < len(beforeRunes)-prefixLength && + suffixLength < len(afterRunes)-prefixLength && + beforeRunes[len(beforeRunes)-1-suffixLength] == + afterRunes[len(afterRunes)-1-suffixLength] { + suffixLength++ + } + + beforeChangeEnd := len(beforeRunes) - suffixLength + afterChangeEnd := len(afterRunes) - suffixLength + return string(beforeRunes[:prefixLength]), + string(beforeRunes[prefixLength:beforeChangeEnd]), + string(afterRunes[prefixLength:afterChangeEnd]), + string(beforeRunes[beforeChangeEnd:]) +} + +// formatDiffValue pretty-prints JSON and substitutes readable absence markers +// for missing local or LaunchDarkly resources. +func formatDiffValue(value json.RawMessage, label string, missingValue string) (string, error) { + if len(value) == 0 || bytes.Equal(value, []byte("null")) { + if missingValue != "" { + return missingValue, nil + } + if strings.HasPrefix(label, "LaunchDarkly") { + return "(does not exist in LaunchDarkly)", nil + } + return "(does not exist locally)", nil + } + var formatted bytes.Buffer + if err := json.Indent(&formatted, value, "", " "); err != nil { + return "", fmt.Errorf("format variation diff: %w", err) + } + return formatted.String(), nil +} + +func formatToolDetails(tool syncdomain.Tool) string { + var rendered strings.Builder + if tool.Description != nil && *tool.Description != "" { + _, _ = fmt.Fprintf(&rendered, "Description: %s", *tool.Description) + } + writeDiffValue(&rendered, "Schema", tool.Schema, 0) + if len(tool.CustomParameters) != 0 { + writeDiffValue(&rendered, "Custom parameters", tool.CustomParameters, 0) + } + if len(tool.Tags) != 0 { + writeDiffValue(&rendered, "Tags", tool.Tags, 0) + } + return strings.TrimPrefix(rendered.String(), "\n") +} + +func formatSkillDetails(skill syncdomain.Skill) string { + var rendered strings.Builder + if skill.Description != "" { + _, _ = fmt.Fprintf(&rendered, "Description: %s", skill.Description) + } + if skill.Markdown != "" { + if rendered.Len() > 0 { + rendered.WriteString("\n") + } + rendered.WriteString("Markdown:\n") + rendered.WriteString(indentBlock(strings.TrimSpace(skill.Markdown), 2)) + } + if rendered.Len() == 0 { + return "(attached)" + } + return rendered.String() +} + +// writeDiffValue writes one labeled value as an indented YAML-like block. +func writeDiffValue(rendered *strings.Builder, label string, value any, indent int) { + padding := strings.Repeat(" ", indent) + switch value := value.(type) { + case map[string]any: + if len(value) == 0 { + _, _ = fmt.Fprintf(rendered, "\n%s%s: {}", padding, label) + return + } + _, _ = fmt.Fprintf(rendered, "\n%s%s:", padding, label) + writeDiffMap(rendered, value, indent+2) + case []string: + _, _ = fmt.Fprintf(rendered, "\n%s%s:", padding, label) + for _, item := range value { + _, _ = fmt.Fprintf(rendered, "\n%s- %s", strings.Repeat(" ", indent+2), item) + } + case []any: + if len(value) == 0 { + _, _ = fmt.Fprintf(rendered, "\n%s%s: []", padding, label) + return + } + _, _ = fmt.Fprintf(rendered, "\n%s%s:", padding, label) + writeDiffList(rendered, value, indent+2) + case nil: + _, _ = fmt.Fprintf(rendered, "\n%s%s: null", padding, label) + default: + _, _ = fmt.Fprintf(rendered, "\n%s%s: %v", padding, label, value) + } +} + +// writeDiffMap writes each entry of a map in key order. +func writeDiffMap(rendered *strings.Builder, values map[string]any, indent int) { + for _, key := range slices.Sorted(maps.Keys(values)) { + writeDiffValue(rendered, key, values[key], indent) + } +} + +// writeDiffList writes each item of a list. A map or a list in the list +// starts on the line after its dash. +func writeDiffList(rendered *strings.Builder, values []any, indent int) { + padding := strings.Repeat(" ", indent) + for _, value := range values { + switch nested := value.(type) { + case map[string]any: + if len(nested) == 0 { + _, _ = fmt.Fprintf(rendered, "\n%s- {}", padding) + continue + } + _, _ = fmt.Fprintf(rendered, "\n%s-", padding) + writeDiffMap(rendered, nested, indent+2) + case []any: + if len(nested) == 0 { + _, _ = fmt.Fprintf(rendered, "\n%s- []", padding) + continue + } + _, _ = fmt.Fprintf(rendered, "\n%s-", padding) + writeDiffList(rendered, nested, indent+2) + case nil: + _, _ = fmt.Fprintf(rendered, "\n%s- null", padding) + default: + _, _ = fmt.Fprintf(rendered, "\n%s- %v", padding, value) + } + } +} + +// terminalWidth returns zero for redirected output or unavailable terminal metadata. +func terminalWidth(out io.Writer) int { + file, ok := out.(*os.File) + if !ok || !term.IsTerminal(int(file.Fd())) { + return 0 + } + width, _, err := term.GetSize(int(file.Fd())) + if err != nil { + return 0 + } + return width +} diff --git a/internal/sync/prompt/review.go b/internal/sync/prompt/review.go new file mode 100644 index 00000000..12bf0ab2 --- /dev/null +++ b/internal/sync/prompt/review.go @@ -0,0 +1,165 @@ +package prompt + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "slices" + "strings" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncconsole "github.com/launchdarkly/ldcli/internal/sync/console" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" +) + +const applyQuestion = "\nSync these changes? [y/N] " + +// reviewAndConfirmPlan shows the plan and reports whether to apply it. +// +// A plan that changes only the manifest applies without a question. With +// --yes, every plan applies. In watch mode, a plan without a deletion or an +// archive applies. Every other plan needs the user to agree. +func reviewAndConfirmPlan(options Options, plan Plan, interactive bool) (bool, error) { + if err := writePlanReview(options.ErrorOutput, "plaintext", plan, terminalWidth(options.ErrorOutput)); err != nil { + return false, err + } + if err := plan.BlockingError(); err != nil { + return false, err + } + if !plan.HasChanges() { + if options.OutputKind != "" && options.OutputKind != "plaintext" { + return false, writePlanOutput(options.Output, options.OutputKind, plan) + } + return false, nil + } + autoApply := options.Yes || (options.watching() && !plan.HasDestructiveActions()) + if autoApply || !plan.RequiresConfirmation() { + return true, nil + } + + confirmed, err := confirm(options.Context, options.Input, options.ErrorOutput, interactive, applyQuestion) + if err != nil { + return false, err + } + if !confirmed { + _ = syncconsole.New(options.ErrorOutput).Line("Sync canceled.") + } + return confirmed, nil +} + +// confirm asks a yes or no question and stops when ctx ends. A nil ctx never +// ends. +func confirm(ctx context.Context, input io.Reader, output io.Writer, interactive bool, question string) (bool, error) { + if !interactive { + return false, errors.New("interactive confirmation requires a terminal; rerun with --yes to apply non-interactively") + } + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return false, err + } + + // A read from io.Reader cannot stop when ctx ends, so read in a goroutine. + // The channel has a buffer, so the goroutine can finish after confirm + // returns. + type answer struct { + confirmed bool + err error + } + answers := make(chan answer, 1) + go func() { + confirmed, err := readConfirmation(input, output, question) + answers <- answer{confirmed: confirmed, err: err} + }() + + select { + case <-ctx.Done(): + return false, ctx.Err() + case result := <-answers: + if err := ctx.Err(); err != nil { + return false, err + } + return result.confirmed, result.err + } +} + +func readConfirmation(input io.Reader, output io.Writer, question string) (bool, error) { + if err := syncconsole.New(output).Write(question); err != nil { + return false, err + } + line, err := bufio.NewReader(input).ReadString('\n') + if err != nil && err != io.EOF { + return false, fmt.Errorf("read apply confirmation: %w", err) + } + line = strings.ToLower(strings.TrimSpace(line)) + return line == "y" || line == "yes", nil +} + +// cleanupOrphanedAttachments shows the tool and skill files that no local +// variation uses, and deletes them after the user agrees. Without --yes and +// without a terminal, it keeps the files. Before it deletes a file, it makes +// sure again that no variation uses the file. +func cleanupOrphanedAttachments(options Options, store synclocal.Store, interactive bool) error { + orphaned, err := store.OrphanedAttachments() + if err != nil || len(orphaned) == 0 { + return err + } + + console := syncconsole.New(options.ErrorOutput) + _ = console.Line("\nUnreferenced local attachment files:") + currentProject := "" + for _, attachment := range orphaned { + if attachment.ProjectKey != currentProject { + currentProject = attachment.ProjectKey + _ = console.Printf(" Project: %s\n", currentProject) + } + kind := string(attachment.Kind) + _ = console.Printf( + " %s %q\n %s/%s\n", strings.ToUpper(kind[:1])+kind[1:], attachment.Key, syncdomain.RootDir, attachment.Path, + ) + } + + if !options.Yes && !interactive { + _ = console.Line("Unreferenced attachment files kept. Rerun with --yes to delete them.") + return nil + } + if !options.Yes { + confirmed, err := confirm( + options.Context, options.Input, options.ErrorOutput, interactive, + "\nDelete these unreferenced local files? [y/N] ", + ) + if err != nil { + return err + } + if !confirmed { + _ = console.Line("Unreferenced attachment files kept.") + return nil + } + } + + // A file can become used again while the user reads the question. + current, err := store.OrphanedAttachments() + if err != nil { + return err + } + orphaned = slices.DeleteFunc(orphaned, func(attachment synclocal.OrphanedAttachment) bool { + return !slices.Contains(current, attachment) + }) + if len(orphaned) == 0 { + _ = console.Line("No selected attachment files remain unreferenced.") + return nil + } + + deleted, err := store.DeleteAttachments(orphaned) + if err != nil { + return err + } + _ = console.Line("Deleted unreferenced attachment files:") + for _, file := range deleted { + _ = console.Printf("- %s/%s\n", syncdomain.RootDir, file) + } + return nil +} diff --git a/internal/sync/prompt/terminal_test.go b/internal/sync/prompt/review_test.go similarity index 98% rename from internal/sync/prompt/terminal_test.go rename to internal/sync/prompt/review_test.go index adead02f..1175d5f5 100644 --- a/internal/sync/prompt/terminal_test.go +++ b/internal/sync/prompt/review_test.go @@ -58,7 +58,7 @@ func TestConfirmApply(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { var prompt bytes.Buffer - confirmed, err := confirmApply(strings.NewReader(test.input), &prompt, test.terminal) + confirmed, err := confirm(context.Background(), strings.NewReader(test.input), &prompt, test.terminal, applyQuestion) assert.Equal(t, test.confirmed, confirmed) if test.wantError != "" { diff --git a/internal/sync/prompt/runner.go b/internal/sync/prompt/runner.go index cae02421..8b770ce1 100644 --- a/internal/sync/prompt/runner.go +++ b/internal/sync/prompt/runner.go @@ -1,14 +1,14 @@ +// Package prompt runs the "ldcli sync prompt" commands. A sync compares the +// local files and LaunchDarkly with the manifest, shows the plan, and applies +// it after the user agrees. package prompt import ( - "context" "errors" "fmt" "io" "os" "os/signal" - "slices" - "strings" "syscall" "github.com/launchdarkly/ldcli/internal/resources" @@ -21,590 +21,225 @@ import ( synclink "github.com/launchdarkly/ldcli/internal/sync/link" synclocal "github.com/launchdarkly/ldcli/internal/sync/local" syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" - syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" - syncrepository "github.com/launchdarkly/ldcli/internal/sync/repository" syncsource "github.com/launchdarkly/ldcli/internal/sync/source" ) -// CommandAction is one focused prompt sync operation. -type CommandAction interface { - promptSyncAction() -} - -// SyncAction reconciles the workspace once or whenever watched files change. -type SyncAction struct { - Watch bool - DryRun bool -} - -// AddAction materializes selected LaunchDarkly variations as local files. -type AddAction struct { - Variations []syncdomain.ResourceID - DryRun bool -} - -// AttachAction adds one tool or skill reference to a managed variation. -type AttachAction struct { - Kind syncdomain.AttachmentKind - Key string - Target *syncdomain.ResourceID -} - -// DetachAction stops managing selected local variations. -type DetachAction struct { - Variations []syncdomain.ResourceID -} - -// LinkAction creates a managed variation backed by an external prompt file. -type LinkAction struct { - File string - Format string - Target *synclink.Target -} - -func (SyncAction) promptSyncAction() {} -func (AddAction) promptSyncAction() {} -func (AttachAction) promptSyncAction() {} -func (DetachAction) promptSyncAction() {} -func (LinkAction) promptSyncAction() {} - -// Options contains command input and streams for one prompt synchronization. -type Options struct { - WorkingDirectory string - AccessToken string - BaseURI string - OutputKind string - Action CommandAction - ConflictPolicy ConflictPolicy - Yes bool - NoInput bool - Context context.Context - Input io.Reader - Output io.Writer - ErrorOutput io.Writer -} - -func (options Options) dryRun() bool { - switch action := options.Action.(type) { - case SyncAction: - return action.DryRun - case AddAction: - return action.DryRun - default: - return false - } -} - -func (options Options) watching() bool { - action, ok := options.Action.(SyncAction) - return ok && action.Watch -} - -type bootstrapRunner func(syncbootstrap.Options) error -type detachRunner func(syncdetach.Options) error -type linkRunner func(synclink.Options) (string, error) - type manifestStore interface { Load(projectKeys []string) (syncmanifest.Manifest, error) Update(previous, next syncmanifest.Manifest) (syncmanifest.Manifest, error) } +// syncWorkspace is the Git repository that one command syncs. type syncWorkspace struct { root string local synclocal.Store manifest manifestStore } -type localFileResourcesByID map[ResourceID]syncdomain.SyncedResource - -type attachmentID struct { - projectKey string - kind syncdomain.AttachmentKind - key string -} - -// Runner coordinates prompt synchronization using existing config APIs. +// Runner runs the prompt sync commands. Tests replace its function fields. type Runner struct { client resources.Client - bootstrap bootstrapRunner - detach detachRunner - link linkRunner + bootstrap func(syncbootstrap.Options) error + detach func(syncdetach.Options) error + link func(synclink.Options) (string, error) watch watchRunner - isTerminal terminalCheck + isTerminal func(io.Reader, io.Writer) bool } -// NewRunner creates a prompt synchronization runner. +// NewRunner creates a runner that calls LaunchDarkly through client. func NewRunner(client resources.Client) Runner { return Runner{ - client: client, bootstrap: syncbootstrap.Run, detach: syncdetach.Run, link: synclink.Run, - watch: watchWorkspace, isTerminal: syncinteractive.StreamsAreTerminal, + client: client, + bootstrap: syncbootstrap.Run, + detach: syncdetach.Run, + link: synclink.Run, + watch: watchWorkspace, + isTerminal: syncinteractive.StreamsAreTerminal, } } -// Run resolves the Git workspace and performs the requested prompt sync flow. +// Run finds the Git repository of the working directory and runs the action. func (runner Runner) Run(options Options) error { if err := validateOptions(options); err != nil { return err } - if options.Action == nil { - options.Action = SyncAction{} - } - // Every path stored in wrappers or the manifest is repository-relative, so - // resolve the canonical Git root before dispatching any command mode. - resolvedWorkspace, err := syncsource.NewResolver().Resolve(options.WorkingDirectory) + options = options.withDefaults() + + // Every path in a variation file or in the manifest is relative to the + // repository root, so find the root first. + resolved, err := syncsource.NewResolver().Resolve(options.WorkingDirectory) if err != nil { return err } - apiClient := syncapi.NewClient(runner.client, options.AccessToken, options.BaseURI) workspace := syncWorkspace{ - root: resolvedWorkspace.Root, - local: synclocal.NewStore(resolvedWorkspace.Root), - manifest: syncmanifest.NewStore(apiClient, resolvedWorkspace.Source), - } - localDirectoryExists, err := workspace.local.Exists() - if err != nil { - return err + root: resolved.Root, + local: synclocal.NewStore(resolved.Root), + manifest: syncmanifest.NewStore(runner.api(options), resolved.Source), } switch action := options.Action.(type) { + case SyncAction: + return runner.runSync(options, workspace, action) + case AddAction: + return runner.runAdd(options, workspace, action) case DetachAction: - projectKeys, err := discoverProjectKeys(workspace.root) - if err != nil { - return err - } - return runner.detach(syncdetach.Options{ - RepositoryRoot: workspace.root, - Store: workspace.local, - Manifest: workspace.manifest, - ProjectKeys: projectKeys, - Input: options.Input, - Output: options.Output, - Selections: action.Variations, - NoInput: options.NoInput, - }) + return runner.runDetach(options, workspace, action) case LinkAction: - path, err := runner.link(synclink.Options{ - Catalog: apiClient, - Store: workspace.local, - RepositoryRoot: workspace.root, - WorkingDirectory: options.WorkingDirectory, - File: action.File, - Format: action.Format, - Input: options.Input, - Output: options.Output, - Target: action.Target, - NoInput: options.NoInput, - }) - if err != nil { + linked, err := runner.runLink(options, workspace, action) + if err != nil || !linked { return err } - if path == "" { - return nil - } - _ = syncconsole.New(options.Output).Printf( - "Linked %s/%s.\n", - syncdomain.RootDir, - path, - ) - localDirectoryExists = true case AttachAction: - if !localDirectoryExists { - return fmt.Errorf("attach a tool or skill after synchronizing at least one variation") - } - projectKey, variationID := "", "" - if action.Target != nil { - projectKey = action.Target.ProjectKey - variationID = action.Target.LookupKey - } - if err := attachToVariation(workspace.local, apiClient, attachOptions{ - RepositoryRoot: workspace.root, - ProjectKey: projectKey, - VariationID: variationID, - Kind: action.Kind, - Key: action.Key, - Interactive: !options.NoInput && runner.isTerminal(options.Input, options.Output), - Input: options.Input, - Output: options.Output, - }); err != nil { - if errors.Is(err, errAttachmentCanceled) { - return nil - } + attached, err := runner.runAttach(options, workspace, action) + if err != nil || !attached { return err } - case AddAction: - return runner.bootstrap(syncbootstrap.Options{ - Catalog: apiClient, - Attachments: apiClient, - Store: workspace.local, - Manifest: workspace.manifest, - Input: options.Input, - Output: options.Output, - Initial: !localDirectoryExists, - DryRun: action.DryRun, - Selections: action.Variations, - NoInput: options.NoInput, - }) - case SyncAction: default: return fmt.Errorf("unsupported prompt sync action %T", action) } - projectKeys, err := discoverProjectKeys(workspace.root) - if err != nil { - return err - } - syncAction, isSync := options.Action.(SyncAction) - if !localDirectoryExists && len(projectKeys) == 0 { - if err := runner.bootstrap(syncbootstrap.Options{ - Catalog: apiClient, - Attachments: apiClient, - Store: workspace.local, - Manifest: workspace.manifest, - Input: options.Input, - Output: options.Output, - Initial: !localDirectoryExists, - DryRun: isSync && syncAction.DryRun, - NoInput: options.NoInput, - }); err != nil { - return err - } - if !isSync || !syncAction.Watch { - return nil - } - localDirectoryExists, err = workspace.local.Exists() - if err != nil { - return err - } - if !localDirectoryExists { - return nil - } - } - - if isSync && syncAction.Watch { - ctx := options.Context - if ctx == nil { - ctx = context.Background() - } - ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) - defer stop() - - options.Context = ctx - return runner.watch(ctx, workspace.root, watchDebounce, func(watcher *sourceWatcher) error { - return runner.runWorkspaceSync(options, workspace, watcher) - }, options.ErrorOutput) - } - + // Link and attach change only local files. One sync applies the change. return runner.runWorkspaceSync(options, workspace, nil) } -// runWorkspaceSync plans, reviews, revalidates, and executes one workspace sync. -func (runner Runner) runWorkspaceSync(options Options, workspace syncWorkspace, watcher *sourceWatcher) error { - var watched *watchedSources - if watcher != nil { - snapshot, err := sourceSnapshot(workspace.root) - if err != nil { - return err - } - watched = &watchedSources{ - watcher: watcher, - snapshot: snapshot, - debounce: watchDebounce, - } - } - - apiClient := syncapi.NewClient(runner.client, options.AccessToken, options.BaseURI) - reviewedProjectKeys, err := discoverProjectKeys(workspace.root) - if err != nil { - return err - } - // The manifest is the common ancestor in a three-way comparison between - // current local files and current LaunchDarkly state. - baseline, err := workspace.manifest.Load(reviewedProjectKeys) +// runSync syncs once, or watches the workspace. A workspace without any sync +// files starts with an add, so that the user can choose the first variations. +func (runner Runner) runSync(options Options, workspace syncWorkspace, action SyncAction) error { + exists, err := workspace.local.Exists() if err != nil { return err } - reviewedPlan, _, err := loadWorkspacePlan(workspace.root, baseline, apiClient) + projectKeys, err := discoverProjectKeys(workspace.root) if err != nil { return err } - - if options.dryRun() { - if err := writePlanOutput(options.Output, options.OutputKind, reviewedPlan); err != nil { + if !exists && len(projectKeys) == 0 { + if err := runner.bootstrap(runner.bootstrapOptions(options, workspace, true, action.DryRun, nil)); err != nil { return err } - return reviewedPlan.BlockingError() - } - - interactive := !options.NoInput && runner.isTerminal(options.Input, options.ErrorOutput) - conflictResult, err := resolveConflicts(options, reviewedPlan, options.Input, interactive, watched) - if err != nil { - return err - } - if conflictResult.sourcesChanged { - return errRefreshWatchPlan - } - if conflictResult.aborted { - return nil - } - - resolvedPlan := applyConflictResolutions(reviewedPlan, conflictResult.resolutions) - shouldContinue, err := reviewAndConfirmPlan(options, resolvedPlan, interactive) - if err != nil { - return err - } - if !shouldContinue { - if !resolvedPlan.HasChanges() { - return cleanupOrphanedAttachments(options, workspace.local, interactive) - } - return nil - } - - // Re-read both sides after review so no action uses stale state. - currentProjectKeys, err := discoverProjectKeys(workspace.root) - if err != nil { - return err - } - if !slices.Equal(reviewedProjectKeys, currentProjectKeys) { - if options.watching() { - return errRefreshWatchPlan - } - return fmt.Errorf("sync projects changed after review; run sync again") - } - currentManifest, err := workspace.manifest.Load(currentProjectKeys) - if err != nil { - return err - } - currentPlan, currentLocalFiles, err := loadWorkspacePlan(workspace.root, currentManifest, apiClient) - if err != nil { - return err - } - if !samePlanState(reviewedPlan, currentPlan) { - if options.watching() { - return errRefreshWatchPlan - } - return fmt.Errorf("sync state changed after review; run sync again") - } - - // Apply the user's conflict choices to freshly read state, never to the - // potentially stale objects that were rendered during review. - currentPlan = applyConflictResolutions(currentPlan, conflictResult.resolutions) - outcomes, updatedManifest, executionErr := executePlan( - workspace.root, - workspace.local, - apiClient, - currentManifest, - currentPlan, - currentLocalFiles, - ) - if _, err := workspace.manifest.Update(currentManifest, updatedManifest); err != nil { - executionErr = errors.Join(executionErr, err) - } - if err := workspace.local.RemoveEmptyDirectories(); err != nil { - executionErr = errors.Join(executionErr, err) - } - if err := writeOutcomeOutput(options.Output, options.OutputKind, outcomes); err != nil { - executionErr = errors.Join(executionErr, err) - } - if executionErr == nil { - executionErr = cleanupOrphanedAttachments(options, workspace.local, interactive) - } - return executionErr -} - -// validateOptions rejects command modes whose side effects or UX conflict. -func validateOptions(options Options) error { - switch action := options.Action.(type) { - case nil: - case SyncAction: - if action.Watch && action.DryRun { - return fmt.Errorf("watch does not support --dry-run") - } - case AddAction: - case AttachAction: - if action.Kind != syncdomain.AttachmentTool && action.Kind != syncdomain.AttachmentSkill { - return fmt.Errorf("attachment kind must be tool or skill") - } - if action.Target != nil && action.Target.Kind != syncdomain.KindVariation { - return fmt.Errorf("attachment target must be a variation") - } - case DetachAction: - case LinkAction: - if action.File == "" { - return fmt.Errorf("linked file is required") - } - if action.Format == "" { - return fmt.Errorf("--format is required") + if !action.Watch { + return nil } - if err := syncreference.ValidateFormat(action.Format); err != nil { + if exists, err = workspace.local.Exists(); err != nil || !exists { return err } - if action.Target != nil && action.Target.ModelConfigKey == "" { - return fmt.Errorf("--model-config-key is required with --to") - } - default: - return fmt.Errorf("unsupported prompt sync action %T", action) - } - return nil -} - -// loadWorkspacePlan keeps local file data separate from the canonical local -// resources used by the three-way plan. -func loadWorkspacePlan( - repositoryRoot string, - baseline syncmanifest.Manifest, - client syncapi.Client, -) (Plan, localFileResourcesByID, error) { - localFileResources, err := synclocal.CompileWorkspace(repositoryRoot) - if errors.Is(err, synclocal.ErrNoDirectory) { - localFileResources = nil - err = nil - } - if err != nil { - return Plan{}, nil, err - } - canonicalLocalResources, err := canonicalizeLocalVariationModels(localFileResources, client.ModelConfig) - if err != nil { - return Plan{}, nil, err - } - - localFilesByID := make(localFileResourcesByID, len(localFileResources)) - resourceIDs := make(map[ResourceID]struct{}, len(localFileResources)+len(baseline.Resources)) - for _, resource := range localFileResources { - id := ResourceID{Kind: resource.Kind, ProjectKey: resource.ProjectKey, LookupKey: resource.LookupKey} - localFilesByID[id] = resource - resourceIDs[id] = struct{}{} - } - for _, resource := range baseline.Resources { - if resource.ResourceKind == syncdomain.KindVariation { - resourceIDs[resource.ID()] = struct{}{} - } } - serverResources := make(map[ResourceID]ServerResource, len(resourceIDs)) - attachments := newAttachmentHydrator(client) - for id := range resourceIDs { - resource, err := readServerResource(client, attachments, id) - if err != nil { - return Plan{}, nil, err - } - serverResources[id] = resource + if !action.Watch { + return runner.runWorkspaceSync(options, workspace, nil) } - return BuildPlan(baseline, canonicalLocalResources, serverResources), localFilesByID, nil + ctx, stop := signal.NotifyContext(options.Context, os.Interrupt, syscall.SIGTERM) + defer stop() + options.Context = ctx + return runner.watch(ctx, workspace.root, watchDebounce, func(watcher *sourceWatcher) error { + return runner.runWorkspaceSync(options, workspace, watcher) + }, options.ErrorOutput) } -func discoverProjectKeys(repositoryRoot string) ([]string, error) { - files, err := synclocal.SourceFiles(repositoryRoot) - if err != nil { - return nil, err - } - deleted, err := syncrepository.DeletedPaths(repositoryRoot) +func (runner Runner) runAdd(options Options, workspace syncWorkspace, action AddAction) error { + exists, err := workspace.local.Exists() if err != nil { - return nil, err - } - - var projectKeys []string - for _, file := range append(files, deleted...) { - if projectKey, ok := projectKeyFromManagedPath(file); ok { - projectKeys = append(projectKeys, projectKey) - } - } - slices.Sort(projectKeys) - return slices.Compact(projectKeys), nil -} - -func projectKeyFromManagedPath(file string) (string, bool) { - parts := strings.Split(file, "/") - if len(parts) < 4 || parts[0] != syncdomain.RootDir || parts[1] == "" { - return "", false - } - switch { - case len(parts) == 5 && parts[2] == "configs" && strings.HasSuffix(parts[4], ".prompt.md"): - case len(parts) == 4 && parts[2] == "tools" && strings.HasSuffix(parts[3], ".json"): - case len(parts) == 4 && parts[2] == "skills" && strings.HasSuffix(parts[3], ".md"): - default: - return "", false + return err } - return parts[1], true -} - -// attachmentHydrator reads each shared dependency once while building a plan. -type attachmentHydrator struct { - client syncapi.Client - cache map[attachmentID]syncdomain.Attachment -} - -func newAttachmentHydrator(client syncapi.Client) *attachmentHydrator { - return &attachmentHydrator{client: client, cache: make(map[attachmentID]syncdomain.Attachment)} + return runner.bootstrap(runner.bootstrapOptions(options, workspace, !exists, action.DryRun, action.Variations)) } -func (hydrator *attachmentHydrator) read(projectKey string, kind syncdomain.AttachmentKind, key string) (syncdomain.Attachment, error) { - id := attachmentID{projectKey: projectKey, kind: kind, key: key} - if attachment, ok := hydrator.cache[id]; ok { - return attachment, nil - } - attachment, err := hydrator.client.ReadAttachment(projectKey, kind, key) +func (runner Runner) runDetach(options Options, workspace syncWorkspace, action DetachAction) error { + projectKeys, err := discoverProjectKeys(workspace.root) if err != nil { - return syncdomain.Attachment{}, err - } - hydrator.cache[id] = attachment - return attachment, nil -} - -func (hydrator *attachmentHydrator) hydrate(projectKey string, variation *syncdomain.Variation) error { - variation.Attachments = make([]syncdomain.Attachment, 0, len(variation.Tools)+len(variation.Skills)) - for _, ref := range variation.Tools { - attachment, err := hydrator.read(projectKey, syncdomain.AttachmentTool, ref.Key) - if err != nil { - return err - } - variation.Attachments = append(variation.Attachments, attachment) - } - for _, ref := range variation.Skills { - attachment, err := hydrator.read(projectKey, syncdomain.AttachmentSkill, ref.Key) - if err != nil { - return err - } - variation.Attachments = append(variation.Attachments, attachment) - } - return variation.NormalizeAttachments() -} - -func hydrateServerAttachments(client syncapi.Client, projectKey string, variation *syncdomain.Variation) error { - return newAttachmentHydrator(client).hydrate(projectKey, variation) -} - -// samePlanState reports whether every reviewed decision still has the same inputs. -func samePlanState(reviewed, current Plan) bool { - if len(reviewed.Resources) != len(current.Resources) { - return false - } - for index := range reviewed.Resources { - if !samePlannedResourceState(reviewed.Resources[index], current.Resources[index]) { - return false - } - } - return true -} - -// samePlannedResourceState compares every input that can change a reviewed -// action. Rendered diffs and decoded payload pointers are derived from these values. -func samePlannedResourceState(reviewed, current PlannedResource) bool { - return reviewed.ID == current.ID && - reviewed.Action == current.Action && - reviewed.BaselineFingerprint == current.BaselineFingerprint && - reviewed.LocalFingerprint == current.LocalFingerprint && - reviewed.ServerFingerprint == current.ServerFingerprint && - reviewed.ServerMode == current.ServerMode && - reviewed.Upsert == current.Upsert && - reviewed.ServerHasStaleAttachmentPins == current.ServerHasStaleAttachmentPins && - sameAttachmentPins(reviewed.Server, current.Server) -} - -// sameAttachmentPins compares exact server references because canonical -// fingerprints intentionally exclude runtime versions. -func sameAttachmentPins(reviewed, current *syncdomain.Variation) bool { - if reviewed == nil || current == nil { - return reviewed == current + return err } - return slices.Equal(reviewed.Tools, current.Tools) && slices.Equal(reviewed.Skills, current.Skills) + return runner.detach(syncdetach.Options{ + RepositoryRoot: workspace.root, + Store: workspace.local, + Manifest: workspace.manifest, + ProjectKeys: projectKeys, + Input: options.Input, + Output: options.Output, + Selections: action.Variations, + NoInput: options.NoInput, + }) +} + +// runLink creates the linked variation file. It reports false when the user +// cancels. +func (runner Runner) runLink(options Options, workspace syncWorkspace, action LinkAction) (bool, error) { + path, err := runner.link(synclink.Options{ + Catalog: runner.api(options), + Store: workspace.local, + RepositoryRoot: workspace.root, + WorkingDirectory: options.WorkingDirectory, + File: action.File, + Format: action.Format, + Input: options.Input, + Output: options.Output, + Target: action.Target, + NoInput: options.NoInput, + }) + if err != nil || path == "" { + return false, err + } + _ = syncconsole.New(options.Output).Printf("Linked %s/%s.\n", syncdomain.RootDir, path) + return true, nil +} + +// runAttach adds the attachment to a local variation. It reports false when +// the user cancels. +func (runner Runner) runAttach(options Options, workspace syncWorkspace, action AttachAction) (bool, error) { + exists, err := workspace.local.Exists() + if err != nil { + return false, err + } + if !exists { + return false, errors.New("attach a tool or skill after synchronizing at least one variation") + } + + attach := attachOptions{ + RepositoryRoot: workspace.root, + Kind: action.Kind, + Key: action.Key, + // The attach forms write to the output stream, so check that stream. + Interactive: !options.NoInput && runner.isTerminal(options.Input, options.Output), + Input: options.Input, + Output: options.Output, + } + if action.Target != nil { + attach.ProjectKey, attach.VariationID = action.Target.ProjectKey, action.Target.LookupKey + } + err = attachToVariation(workspace.local, runner.api(options), attach) + if errors.Is(err, errAttachmentCanceled) { + return false, nil + } + return err == nil, err +} + +func (runner Runner) bootstrapOptions( + options Options, + workspace syncWorkspace, + initial, dryRun bool, + selections []syncdomain.ResourceID, +) syncbootstrap.Options { + api := runner.api(options) + return syncbootstrap.Options{ + Catalog: api, + Attachments: api, + Store: workspace.local, + Manifest: workspace.manifest, + Input: options.Input, + Output: options.Output, + Initial: initial, + DryRun: dryRun, + Selections: selections, + NoInput: options.NoInput, + } +} + +// api returns the LaunchDarkly client for the command. +func (runner Runner) api(options Options) syncapi.Client { + return syncapi.NewClient(runner.client, options.AccessToken, options.BaseURI) +} + +// interactive reports whether the command can ask the user for input. +func (runner Runner) interactive(options Options) bool { + return !options.NoInput && runner.isTerminal(options.Input, options.ErrorOutput) } diff --git a/internal/sync/prompt/runner_test.go b/internal/sync/prompt/runner_test.go index 61034660..55aef41a 100644 --- a/internal/sync/prompt/runner_test.go +++ b/internal/sync/prompt/runner_test.go @@ -272,8 +272,8 @@ func TestAttachToVariationPreservesExistingSharedAttachmentEdits(t *testing.T) { resources, err := synclocal.CompileWorkspace(root) require.NoError(t, err) for _, resource := range resources { - require.Len(t, resource.Attachments, 1) - assert.Equal(t, localDescription, *resource.Attachments[0].Tool.Description) + require.Len(t, resource.Variation.Attachments, 1) + assert.Equal(t, localDescription, *resource.Variation.Attachments[0].Tool.Description) } } @@ -300,7 +300,7 @@ func TestToolSearchPageReturnsTrueLatestVersion(t *testing.T) { assert.Equal(t, 2, transport.requests) } -func TestAddAttachmentContentPreservesOtherKinds(t *testing.T) { +func TestAttachPreservesOtherKinds(t *testing.T) { skill := syncdomain.Skill{Key: "support", Markdown: "# Support"} variation := syncdomain.Variation{ Skills: []syncdomain.AttachmentRef{{Key: "support"}}, @@ -308,7 +308,7 @@ func TestAddAttachmentContentPreservesOtherKinds(t *testing.T) { } tool := syncdomain.Tool{Key: "search", Schema: map[string]any{"type": "object"}} - addAttachmentContent(&variation, syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Version: 3, Tool: &tool}) + variation.Attach(syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Version: 3, Tool: &tool}) assert.Equal(t, []syncdomain.AttachmentRef{{Key: "support"}}, variation.Skills) assert.Equal(t, []syncdomain.AttachmentRef{{Key: "search"}}, variation.Tools) diff --git a/internal/sync/prompt/state.go b/internal/sync/prompt/state.go new file mode 100644 index 00000000..7c7a0c98 --- /dev/null +++ b/internal/sync/prompt/state.go @@ -0,0 +1,191 @@ +package prompt + +import ( + "errors" + "slices" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" + syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" + syncrepository "github.com/launchdarkly/ldcli/internal/sync/repository" +) + +// workspaceState is everything that one plan depends on. The plan compares +// canonical local variations. localFiles keeps each variation as its file +// stores it, so that a local write keeps the form of the file. +type workspaceState struct { + projectKeys []string + manifest syncmanifest.Manifest + plan Plan + localFiles map[ResourceID]syncdomain.SyncedResource +} + +// loadState reads the manifest, the local files, and LaunchDarkly, and +// builds the plan. +func (workspace syncWorkspace) loadState(client syncapi.Client) (workspaceState, error) { + projectKeys, err := discoverProjectKeys(workspace.root) + if err != nil { + return workspaceState{}, err + } + // The manifest is the common ancestor in a three-way comparison of the + // local files and LaunchDarkly. + manifest, err := workspace.manifest.Load(projectKeys) + if err != nil { + return workspaceState{}, err + } + plan, localFiles, err := loadWorkspacePlan(workspace.root, manifest, client) + if err != nil { + return workspaceState{}, err + } + return workspaceState{projectKeys: projectKeys, manifest: manifest, plan: plan, localFiles: localFiles}, nil +} + +// loadWorkspacePlan reads the local variations and their LaunchDarkly +// versions, and compares both with the baseline. It also returns the local +// variations as their files store them. +func loadWorkspacePlan( + repositoryRoot string, + baseline syncmanifest.Manifest, + client syncapi.Client, +) (Plan, map[ResourceID]syncdomain.SyncedResource, error) { + localFiles, err := compileWorkspace(repositoryRoot) + if err != nil { + return Plan{}, nil, err + } + local, err := canonicalizeLocalVariationModels(localFiles, client.ModelConfig) + if err != nil { + return Plan{}, nil, err + } + + localFilesByID := make(map[ResourceID]syncdomain.SyncedResource, len(localFiles)) + ids := make(map[ResourceID]struct{}, len(localFiles)+len(baseline.Resources)) + for _, resource := range localFiles { + localFilesByID[resource.ID()] = resource + ids[resource.ID()] = struct{}{} + } + for _, resource := range baseline.Resources { + if resource.ResourceKind == syncdomain.KindVariation { + ids[resource.ID()] = struct{}{} + } + } + + attachments := newAttachmentCache(client) + server := make(map[ResourceID]ServerResource, len(ids)) + for id := range ids { + resource, err := readServerResource(client, attachments, id) + if err != nil { + return Plan{}, nil, err + } + server[id] = resource + } + return BuildPlan(baseline, local, server), localFilesByID, nil +} + +// readServerResource reads one variation from LaunchDarkly with the content +// of its tools and skills. +func readServerResource(client syncapi.Client, attachments *attachmentCache, id syncdomain.ResourceID) (ServerResource, error) { + configKey, variationKey, err := id.VariationKeys() + if err != nil { + return ServerResource{}, err + } + state, err := client.ReadVariation(id.ProjectKey, configKey, variationKey) + if err != nil { + return ServerResource{}, err + } + + resource := ServerResource{ConfigMode: state.ConfigMode} + if state.Exists { + if err := attachments.hydrate(id.ProjectKey, &state.Variation); err != nil { + return ServerResource{}, err + } + resource.Variation = &state.Variation + } + return resource, nil +} + +// discoverProjectKeys returns each project that has a local sync file, or +// that had one before a deletion that Git reports. +func discoverProjectKeys(repositoryRoot string) ([]string, error) { + files, err := synclocal.SourceFiles(repositoryRoot) + if err != nil { + return nil, err + } + deleted, err := syncrepository.DeletedPaths(repositoryRoot) + if err != nil { + return nil, err + } + + var projectKeys []string + for _, file := range append(files, deleted...) { + if id, ok := synclocal.ParseManagedPath(file); ok { + projectKeys = append(projectKeys, id.ProjectKey) + } + } + slices.Sort(projectKeys) + return slices.Compact(projectKeys), nil +} + +// compileWorkspace reads the local variations. A workspace without a managed +// directory has no variations. +func compileWorkspace(repositoryRoot string) ([]syncdomain.SyncedResource, error) { + resources, err := synclocal.CompileWorkspace(repositoryRoot) + if errors.Is(err, synclocal.ErrNoDirectory) { + return nil, nil + } + return resources, err +} + +// attachmentCache reads each tool and skill from LaunchDarkly once while it +// builds a plan, because many variations can share one attachment. +type attachmentCache struct { + client syncapi.Client + attachments map[syncdomain.ResourceID]syncdomain.Attachment +} + +func newAttachmentCache(client syncapi.Client) *attachmentCache { + return &attachmentCache{client: client, attachments: make(map[syncdomain.ResourceID]syncdomain.Attachment)} +} + +func (cache *attachmentCache) hydrate(projectKey string, variation *syncdomain.Variation) error { + return variation.HydrateAttachments(func(kind syncdomain.AttachmentKind, key string) (syncdomain.Attachment, error) { + id := syncdomain.ResourceID{Kind: syncdomain.Kind(kind), ProjectKey: projectKey, LookupKey: key} + if attachment, ok := cache.attachments[id]; ok { + return attachment, nil + } + attachment, err := cache.client.ReadAttachment(projectKey, kind, key) + if err != nil { + return syncdomain.Attachment{}, err + } + cache.attachments[id] = attachment + return attachment, nil + }) +} + +// samePlanState reports whether each reviewed decision still has the same input. +func samePlanState(reviewed, current Plan) bool { + return slices.EqualFunc(reviewed.Resources, current.Resources, samePlannedResourceState) +} + +// samePlannedResourceState compares every input that can change the action of +// a reviewed resource. The diff and the decoded variations derive from them. +func samePlannedResourceState(reviewed, current PlannedResource) bool { + return reviewed.ID == current.ID && + reviewed.Action == current.Action && + reviewed.BaselineFingerprint == current.BaselineFingerprint && + reviewed.LocalFingerprint == current.LocalFingerprint && + reviewed.ServerFingerprint == current.ServerFingerprint && + reviewed.ServerMode == current.ServerMode && + reviewed.Upsert == current.Upsert && + reviewed.ServerHasStaleAttachmentPins == current.ServerHasStaleAttachmentPins && + sameAttachmentPins(reviewed.Server, current.Server) +} + +// sameAttachmentPins compares the exact versions that LaunchDarkly pins. The +// fingerprints do not include versions. +func sameAttachmentPins(reviewed, current *syncdomain.Variation) bool { + if reviewed == nil || current == nil { + return reviewed == current + } + return slices.Equal(reviewed.Tools, current.Tools) && slices.Equal(reviewed.Skills, current.Skills) +} diff --git a/internal/sync/prompt/sync.go b/internal/sync/prompt/sync.go new file mode 100644 index 00000000..f70b2733 --- /dev/null +++ b/internal/sync/prompt/sync.go @@ -0,0 +1,121 @@ +package prompt + +import ( + "errors" + "slices" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" +) + +// runWorkspaceSync runs one sync in five steps: +// +// 1. Read the manifest, the local files, and LaunchDarkly, and build a plan. +// 2. Ask the user to resolve each conflict. +// 3. Show the plan and ask the user to apply it. +// 4. Read the state again, and stop if it changed after the review. +// 5. Apply the plan, record the new baseline, and report the results. +// +// In watch mode, watcher is not nil. A file change during the review returns +// errRefreshWatchPlan, so that the watch loop builds a new plan. +func (runner Runner) runWorkspaceSync(options Options, workspace syncWorkspace, watcher *sourceWatcher) error { + options = options.withDefaults() + client := runner.api(options) + + var watched *watchedSources + if watcher != nil { + snapshot, err := sourceSnapshot(workspace.root) + if err != nil { + return err + } + watched = &watchedSources{watcher: watcher, snapshot: snapshot, debounce: watchDebounce} + } + + reviewed, err := workspace.loadState(client) + if err != nil { + return err + } + if options.dryRun() { + if err := writePlanOutput(options.Output, options.OutputKind, reviewed.plan); err != nil { + return err + } + return reviewed.plan.BlockingError() + } + + interactive := runner.interactive(options) + conflicts, err := resolveConflicts(options, reviewed.plan, options.Input, interactive, watched) + switch { + case err != nil: + return err + case conflicts.sourcesChanged: + return errRefreshWatchPlan + case conflicts.aborted: + return nil + } + + resolved := applyConflictResolutions(reviewed.plan, conflicts.resolutions) + proceed, err := reviewAndConfirmPlan(options, resolved, interactive) + if err != nil { + return err + } + if !proceed { + if !resolved.HasChanges() { + return cleanupOrphanedAttachments(options, workspace.local, interactive) + } + return nil + } + + // Read both sides again, so that no action uses state from before the review. + current, err := workspace.loadState(client) + if err != nil { + return err + } + if err := checkUnchanged(options, reviewed, current); err != nil { + return err + } + return runner.applyPlan(options, workspace, client, current, conflicts.resolutions, interactive) +} + +// checkUnchanged returns an error if the state changed after the review. +func checkUnchanged(options Options, reviewed, current workspaceState) error { + if slices.Equal(reviewed.projectKeys, current.projectKeys) && samePlanState(reviewed.plan, current.plan) { + return nil + } + if options.watching() { + return errRefreshWatchPlan + } + if !slices.Equal(reviewed.projectKeys, current.projectKeys) { + return errors.New("sync projects changed after review; run sync again") + } + return errors.New("sync state changed after review; run sync again") +} + +// applyPlan applies the conflict choices to the current plan, executes it, +// and records the new baseline. It removes unused attachment files only when +// every step succeeds. +func (runner Runner) applyPlan( + options Options, + workspace syncWorkspace, + client syncapi.Client, + current workspaceState, + resolutions map[syncdomain.ResourceID]ConflictResolution, + interactive bool, +) error { + plan := applyConflictResolutions(current.plan, resolutions) + outcomes, next, err := executePlan(workspace.root, workspace.local, client, current.manifest, plan, current.localFiles) + + failures := []error{err} + if _, err := workspace.manifest.Update(current.manifest, next); err != nil { + failures = append(failures, err) + } + if err := workspace.local.RemoveEmptyDirectories(); err != nil { + failures = append(failures, err) + } + if err := writeOutcomeOutput(options.Output, options.OutputKind, outcomes); err != nil { + failures = append(failures, err) + } + if err := errors.Join(failures...); err != nil { + return err + } + return cleanupOrphanedAttachments(options, workspace.local, interactive) +} diff --git a/internal/sync/prompt/terminal.go b/internal/sync/prompt/terminal.go deleted file mode 100644 index 064c73c0..00000000 --- a/internal/sync/prompt/terminal.go +++ /dev/null @@ -1,186 +0,0 @@ -package prompt - -import ( - "bufio" - "context" - "fmt" - "io" - "strings" - - syncconsole "github.com/launchdarkly/ldcli/internal/sync/console" - synclocal "github.com/launchdarkly/ldcli/internal/sync/local" -) - -// reviewAndConfirmPlan renders a plan and decides whether execution should continue. -func reviewAndConfirmPlan(options Options, plan Plan, interactive bool) (bool, error) { - if err := writePlanReview(options.ErrorOutput, "plaintext", plan, terminalWidth(options.ErrorOutput)); err != nil { - return false, err - } - if err := plan.BlockingError(); err != nil { - return false, err - } - if !plan.HasChanges() { - if options.OutputKind != "" && options.OutputKind != "plaintext" { - return false, writePlanOutput(options.Output, options.OutputKind, plan) - } - return false, nil - } - autoApply := options.Yes || (options.watching() && !plan.HasDestructiveActions()) - if autoApply || !plan.RequiresConfirmation() { - return true, nil - } - - confirmed, err := confirmApplyWithContext(options.Context, options.Input, options.ErrorOutput, interactive) - if err != nil { - return false, err - } - if !confirmed { - _ = syncconsole.New(options.ErrorOutput).Line("Sync canceled.") - } - return confirmed, nil -} - -type terminalCheck func(io.Reader, io.Writer) bool - -type confirmationResult struct { - confirmed bool - err error -} - -func confirmApplyWithContext(ctx context.Context, input io.Reader, prompt io.Writer, interactive bool) (bool, error) { - return confirmQuestionWithContext(ctx, input, prompt, interactive, "\nSync these changes? [y/N] ") -} - -func confirmQuestionWithContext(ctx context.Context, input io.Reader, prompt io.Writer, interactive bool, question string) (bool, error) { - if ctx == nil { - return confirmQuestion(input, prompt, interactive, question) - } - if err := ctx.Err(); err != nil { - return false, err - } - - // io.Reader has no context-aware read contract. Isolate the blocking read - // so cancellation can return immediately; the buffered channel lets the - // reader finish without waiting for a receiver after the caller exits. - result := make(chan confirmationResult, 1) - go func() { - confirmed, err := confirmQuestion(input, prompt, interactive, question) - result <- confirmationResult{confirmed: confirmed, err: err} - }() - - select { - case <-ctx.Done(): - return false, ctx.Err() - case confirmation := <-result: - if err := ctx.Err(); err != nil { - return false, err - } - return confirmation.confirmed, confirmation.err - } -} - -// confirmApply asks an interactive user to approve planned changes. -func confirmApply(input io.Reader, prompt io.Writer, interactive bool) (bool, error) { - return confirmQuestion(input, prompt, interactive, "\nSync these changes? [y/N] ") -} - -func confirmQuestion(input io.Reader, prompt io.Writer, interactive bool, question string) (bool, error) { - if !interactive { - return false, fmt.Errorf("interactive confirmation requires a terminal; rerun with --yes to apply non-interactively") - } - if err := syncconsole.New(prompt).Write(question); err != nil { - return false, err - } - answer, err := bufio.NewReader(input).ReadString('\n') - if err != nil && err != io.EOF { - return false, fmt.Errorf("read apply confirmation: %w", err) - } - answer = strings.ToLower(strings.TrimSpace(answer)) - return answer == "y" || answer == "yes", nil -} - -// cleanupOrphanedAttachments removes local dependency files after confirmation -// and a final reference check. -func cleanupOrphanedAttachments(options Options, store synclocal.Store, interactive bool) error { - orphaned, err := store.OrphanedAttachments() - if err != nil { - return err - } - if len(orphaned) == 0 { - return nil - } - - console := syncconsole.New(options.ErrorOutput) - _ = console.Line("\nUnreferenced local attachment files:") - currentProject := "" - for _, attachment := range orphaned { - if attachment.ProjectKey != currentProject { - currentProject = attachment.ProjectKey - _ = console.Printf(" Project: %s\n", currentProject) - } - kind := string(attachment.Kind) - kind = strings.ToUpper(kind[:1]) + kind[1:] - _ = console.Printf( - " %s %q\n %s/%s\n", - kind, - attachment.Key, - ".launchdarkly", - attachment.Path, - ) - } - - if !options.Yes && !interactive { - _ = console.Line("Unreferenced attachment files kept. Rerun with --yes to delete them.") - return nil - } - if !options.Yes { - confirmed, err := confirmQuestionWithContext( - options.Context, - options.Input, - options.ErrorOutput, - interactive, - "\nDelete these unreferenced local files? [y/N] ", - ) - if err != nil { - return err - } - if !confirmed { - _ = console.Line("Unreferenced attachment files kept.") - return nil - } - } - - currentOrphans, err := store.OrphanedAttachments() - if err != nil { - return err - } - orphaned = stillOrphanedAttachments(orphaned, currentOrphans) - if len(orphaned) == 0 { - _ = console.Line("No selected attachment files remain unreferenced.") - return nil - } - - deleted, err := store.DeleteAttachments(orphaned) - if err != nil { - return err - } - _ = console.Line("Deleted unreferenced attachment files:") - for _, file := range deleted { - _ = console.Printf("- %s/%s\n", ".launchdarkly", file) - } - return nil -} - -func stillOrphanedAttachments(reviewed, current []synclocal.OrphanedAttachment) []synclocal.OrphanedAttachment { - currentSet := make(map[synclocal.OrphanedAttachment]struct{}, len(current)) - for _, attachment := range current { - currentSet[attachment] = struct{}{} - } - stillOrphaned := make([]synclocal.OrphanedAttachment, 0, len(reviewed)) - for _, attachment := range reviewed { - if _, ok := currentSet[attachment]; ok { - stillOrphaned = append(stillOrphaned, attachment) - } - } - return stillOrphaned -} diff --git a/internal/sync/prompt/watch.go b/internal/sync/prompt/watch.go index 093cdfd6..0717b5ad 100644 --- a/internal/sync/prompt/watch.go +++ b/internal/sync/prompt/watch.go @@ -88,9 +88,8 @@ func watchWorkspace( return err } if errors.Is(err, errRefreshWatchPlan) { - // A source changed during review or the reviewed server state - // became stale. Rebuild immediately instead of waiting for a - // second filesystem event that may never arrive. + // A file or the server state changed during the review. Build + // a new plan now, because a second file event can fail to come. if err := watcher.Refresh(); err != nil { return err } @@ -123,6 +122,31 @@ func watchWorkspace( } } +// watchedSources is the source state that one plan used. It detects a change +// while the user reviews the plan. +type watchedSources struct { + watcher *sourceWatcher + snapshot [sha256.Size]byte + debounce time.Duration +} + +// WaitForChange waits until the content of a watched file differs from the +// state of the plan. +func (watched watchedSources) WaitForChange(ctx context.Context) error { + for { + if err := watched.watcher.WaitForChange(ctx, watched.debounce); err != nil { + return err + } + current, err := sourceSnapshot(watched.watcher.root) + if err != nil { + return err + } + if current != watched.snapshot { + return nil + } + } +} + type sourceWatcher struct { root string managedRoot string @@ -165,8 +189,8 @@ func (watcher *sourceWatcher) Refresh() error { return err } - // Rebuild registrations from source-of-truth state because a wrapper edit - // may add, remove, or redirect an external reference. + // Build the registrations again from the current files, because an edit + // to a variation file can add, remove, or change a linked file. watcher.resetDirectories() watcher.files = make(map[string]struct{}, len(files)) // Watching the repository root lets us observe recreation of a deleted @@ -259,8 +283,8 @@ func (watcher *sourceWatcher) addCreatedDirectory(event fsnotify.Event) error { if !event.Has(fsnotify.Create) { return nil } - // The path may disappear between the event and Stat when an editor uses a - // short-lived temporary directory. There is nothing left to register. + // An editor can remove a temporary directory before Stat runs. Then + // there is nothing to register. info, err := os.Stat(event.Name) if errors.Is(err, os.ErrNotExist) || err == nil && !info.IsDir() { return nil @@ -283,8 +307,8 @@ func (watcher *sourceWatcher) forgetRemovedDirectories(event fsnotify.Event) { removed := filepath.Clean(event.Name) for directory := range watcher.directories { if directory == removed || pathWithin(removed, directory) { - // Remove descendants from our bookkeeping as well; their OS watches - // are no longer useful after an ancestor moves or disappears. + // Also forget each directory below it. Its watch has no use after + // a parent directory moves or disappears. _ = watcher.watcher.Remove(directory) delete(watcher.directories, directory) } @@ -292,7 +316,7 @@ func (watcher *sourceWatcher) forgetRemovedDirectories(event fsnotify.Event) { } // shouldWatchDirectory reports whether a directory contains managed files or -// is an ancestor of a referenced file that may not exist yet. +// is a parent of a linked file that does not exist yet. func (watcher *sourceWatcher) shouldWatchDirectory(directory string) bool { if watcher.insideManagedRoot(directory) { return true @@ -384,8 +408,8 @@ func (watcher *sourceWatcher) addDirectory(path string) error { // a directory that can observe creation of the missing descendants. func (watcher *sourceWatcher) addClosestExistingDirectory(path string) error { path = filepath.Clean(path) - // A referenced file may not exist yet. Its nearest existing ancestor is - // enough to observe creation of the next missing path component. + // A linked file can be absent. A watch on its nearest existing parent + // sees the creation of the next missing directory or file. for pathWithin(watcher.root, path) { info, err := os.Stat(path) if err == nil { diff --git a/internal/sync/reference/adapters/adapter.go b/internal/sync/reference/adapters/adapter.go index 228e9c1b..4bb1f931 100644 --- a/internal/sync/reference/adapters/adapter.go +++ b/internal/sync/reference/adapters/adapter.go @@ -1,49 +1,20 @@ +// Package adapters defines the contract between sync and the external file +// formats that a linked variation can use. package adapters -// Adapter converts between an external prompt format and the common prompt -// representation used by reference syncing. +import syncdomain "github.com/launchdarkly/ldcli/internal/sync" + +// Adapter converts between one external file format and a Prompt. type Adapter interface { Parse([]byte) (Prompt, error) Render(Prompt) ([]byte, error) } -// Prompt is the format-independent data an adapter may extract. +// Prompt is the content that an external file can supply. An empty field +// means that the file format does not store that value. type Prompt struct { - Mode Mode + Mode syncdomain.VariationMode Key string Name string - Messages []Message -} - -// Mode identifies how the parsed prompt is represented in LaunchDarkly. -type Mode string - -const ( - ModeAgent Mode = "agent" - ModeCompletion Mode = "completion" -) - -// Valid reports whether the mode is supported by prompt sync. -func (mode Mode) Valid() bool { - return mode == ModeAgent || mode == ModeCompletion -} - -// Message is one role/content pair in a prompt. -type Message struct { - Role Role - Content string -} - -// Role identifies the speaker for a prompt message. -type Role string - -const ( - RoleSystem Role = "system" - RoleUser Role = "user" - RoleAssistant Role = "assistant" -) - -// Valid reports whether the role can be represented by LaunchDarkly. -func (role Role) Valid() bool { - return role == RoleSystem || role == RoleUser || role == RoleAssistant + Messages []syncdomain.Message } diff --git a/internal/sync/reference/adapters/plain_markdown/plain_markdown.go b/internal/sync/reference/adapters/plainmarkdown/plainmarkdown.go similarity index 52% rename from internal/sync/reference/adapters/plain_markdown/plain_markdown.go rename to internal/sync/reference/adapters/plainmarkdown/plainmarkdown.go index 9b273561..fa92bac6 100644 --- a/internal/sync/reference/adapters/plain_markdown/plain_markdown.go +++ b/internal/sync/reference/adapters/plainmarkdown/plainmarkdown.go @@ -1,30 +1,32 @@ -package plain_markdown +// Package plainmarkdown reads and writes a prompt as one plain Markdown file. +package plainmarkdown import ( - "fmt" + "errors" syncdomain "github.com/launchdarkly/ldcli/internal/sync" "github.com/launchdarkly/ldcli/internal/sync/reference/adapters" ) -// Adapter converts plain Markdown to and from the common prompt representation. +// Adapter stores the prompt as one system message. The file has no mode, key, +// or name. type Adapter struct{} -// Parse reads raw text as one system message. Raw files do not provide mode, -// key, or name metadata. +var _ adapters.Adapter = Adapter{} + +// Parse reads the file text as one system message. func (Adapter) Parse(content []byte) (adapters.Prompt, error) { body := syncdomain.NormalizePromptText(string(content)) - prompt := adapters.Prompt{} - if body != "" { - prompt.Messages = []adapters.Message{{Role: adapters.RoleSystem, Content: body}} + if body == "" { + return adapters.Prompt{}, nil } - return prompt, nil + return adapters.Prompt{Messages: []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: body}}}, nil } -// Render writes a prompt that contains at most one system message. +// Render writes the one system message of the prompt as the file text. func (Adapter) Render(prompt adapters.Prompt) ([]byte, error) { - if len(prompt.Messages) > 1 || len(prompt.Messages) == 1 && prompt.Messages[0].Role != adapters.RoleSystem { - return nil, fmt.Errorf("plain-markdown supports at most one system message") + if len(prompt.Messages) > 1 || len(prompt.Messages) == 1 && prompt.Messages[0].Role != syncdomain.RoleSystem { + return nil, errors.New("plain-markdown supports at most one system message") } if len(prompt.Messages) == 0 { return nil, nil @@ -35,5 +37,3 @@ func (Adapter) Render(prompt adapters.Prompt) ([]byte, error) { } return []byte(body + "\n"), nil } - -var _ adapters.Adapter = Adapter{} diff --git a/internal/sync/reference/adapters/plain_markdown/plain_markdown_test.go b/internal/sync/reference/adapters/plainmarkdown/plainmarkdown_test.go similarity index 75% rename from internal/sync/reference/adapters/plain_markdown/plain_markdown_test.go rename to internal/sync/reference/adapters/plainmarkdown/plainmarkdown_test.go index 2df4e12e..a3314903 100644 --- a/internal/sync/reference/adapters/plain_markdown/plain_markdown_test.go +++ b/internal/sync/reference/adapters/plainmarkdown/plainmarkdown_test.go @@ -1,10 +1,11 @@ -package plain_markdown +package plainmarkdown import ( "testing" "github.com/stretchr/testify/require" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" "github.com/launchdarkly/ldcli/internal/sync/reference/adapters" ) @@ -13,7 +14,7 @@ func TestAdapterParsesAndRendersRawPrompt(t *testing.T) { require.NoError(t, err) require.Equal(t, adapters.Prompt{ - Messages: []adapters.Message{{Role: adapters.RoleSystem, Content: "Be helpful."}}, + Messages: []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Be helpful."}}, }, prompt) rendered, err := (Adapter{}).Render(prompt) @@ -26,15 +27,15 @@ func TestAdapterNormalizesLineEndings(t *testing.T) { require.NoError(t, err) require.Equal(t, adapters.Prompt{ - Messages: []adapters.Message{{ - Role: adapters.RoleSystem, + Messages: []syncdomain.Message{{ + Role: syncdomain.RoleSystem, Content: "First line.\nSecond line.\nThird line.", }}, }, prompt) rendered, err := (Adapter{}).Render(adapters.Prompt{ - Messages: []adapters.Message{{ - Role: adapters.RoleSystem, + Messages: []syncdomain.Message{{ + Role: syncdomain.RoleSystem, Content: "First line.\r\nSecond line.\rThird line.", }}, }) diff --git a/internal/sync/reference/format.go b/internal/sync/reference/format.go index e6c60e17..0a9b9fc5 100644 --- a/internal/sync/reference/format.go +++ b/internal/sync/reference/format.go @@ -1,3 +1,5 @@ +// Package reference converts a variation to and from the external file that a +// linked variation uses. Each file format has one adapter. package reference import ( @@ -5,19 +7,30 @@ import ( syncdomain "github.com/launchdarkly/ldcli/internal/sync" "github.com/launchdarkly/ldcli/internal/sync/reference/adapters" - "github.com/launchdarkly/ldcli/internal/sync/reference/adapters/plain_markdown" + "github.com/launchdarkly/ldcli/internal/sync/reference/adapters/plainmarkdown" ) -// PlainMarkdown identifies the built-in plain Markdown adapter. +// PlainMarkdown identifies the built-in plain Markdown format. const PlainMarkdown = "plain-markdown" -// ValidateFormat reports whether a reference format has a registered adapter. +// adapterFor is the registry of external formats. To add a format, implement +// adapters.Adapter and add one case here. +func adapterFor(format string) (adapters.Adapter, error) { + switch format { + case PlainMarkdown: + return plainmarkdown.Adapter{}, nil + default: + return nil, fmt.Errorf("unsupported referenced prompt format %q", format) + } +} + +// ValidateFormat reports whether a format has an adapter. func ValidateFormat(format string) error { _, err := adapterFor(format) return err } -// Parse converts referenced file content into the common adapter domain. +// Parse reads the content of an external file. func Parse(format string, content []byte) (adapters.Prompt, error) { adapter, err := adapterFor(format) if err != nil { @@ -26,17 +39,19 @@ func Parse(format string, content []byte) (adapters.Prompt, error) { return adapter.Parse(content) } -// ApplyToVariation merges referenced prompt content into a variation's stored metadata. -func ApplyToVariation(format string, content []byte, variation *syncdomain.Variation) (adapters.Prompt, error) { +// ApplyToVariation replaces the prompt content of the variation with the +// content of an external file. If the file stores a mode, key, or name, that +// value also replaces the value in the variation. +func ApplyToVariation(format string, content []byte, variation *syncdomain.Variation) error { prompt, err := Parse(format, content) if err != nil { - return adapters.Prompt{}, err + return err } if prompt.Mode != "" { if !prompt.Mode.Valid() { - return adapters.Prompt{}, fmt.Errorf("unsupported referenced prompt mode %q", prompt.Mode) + return fmt.Errorf("unsupported referenced prompt mode %q", prompt.Mode) } - variation.Mode = syncdomain.VariationMode(prompt.Mode) + variation.Mode = prompt.Mode } if prompt.Key != "" { variation.Key = prompt.Key @@ -44,69 +59,61 @@ func ApplyToVariation(format string, content []byte, variation *syncdomain.Varia if prompt.Name != "" { variation.Name = prompt.Name } - - messages := make([]syncdomain.Message, 0, len(prompt.Messages)) for _, message := range prompt.Messages { - if !message.Role.Valid() { - return adapters.Prompt{}, fmt.Errorf("unsupported referenced prompt role %q", message.Role) + if !syncdomain.ValidMessageRole(message.Role) { + return fmt.Errorf("unsupported referenced prompt role %q", message.Role) } - messages = append(messages, syncdomain.Message{Role: string(message.Role), Content: message.Content}) } + switch variation.Mode { case syncdomain.VariationModeAgent: - if len(messages) > 1 || len(messages) == 1 && messages[0].Role != string(adapters.RoleSystem) { - return adapters.Prompt{}, fmt.Errorf("agent variation %q requires one system message from its reference", variation.Key) + if !atMostOneSystemMessage(prompt.Messages) { + return fmt.Errorf("agent variation %q requires one system message from its reference", variation.Key) } variation.Instructions = "" variation.Messages = nil - if len(messages) == 1 { - variation.Instructions = messages[0].Content + if len(prompt.Messages) == 1 { + variation.Instructions = prompt.Messages[0].Content } case syncdomain.VariationModeCompletion: variation.Instructions = "" - variation.Messages = messages + variation.Messages = append([]syncdomain.Message{}, prompt.Messages...) default: - return adapters.Prompt{}, fmt.Errorf("referenced prompt does not specify a supported mode") + return fmt.Errorf("referenced prompt does not specify a supported mode") } - return prompt, nil + return nil } -// Render converts a variation back to the selected external file format. +// Render converts the variation to the content of an external file. func Render(format string, variation syncdomain.Variation) ([]byte, error) { adapter, err := adapterFor(format) if err != nil { return nil, err } - prompt := adapters.Prompt{Mode: adapters.Mode(variation.Mode), Key: variation.Key, Name: variation.Name} + + prompt := adapters.Prompt{Mode: variation.Mode, Key: variation.Key, Name: variation.Name} switch variation.Mode { case syncdomain.VariationModeAgent: if len(variation.Messages) != 0 { return nil, fmt.Errorf("agent variation %q cannot be represented because it contains messages", variation.Key) } if variation.Instructions != "" { - prompt.Messages = []adapters.Message{{Role: adapters.RoleSystem, Content: variation.Instructions}} + prompt.Messages = []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: variation.Instructions}} } case syncdomain.VariationModeCompletion: for _, message := range variation.Messages { - role := adapters.Role(message.Role) - if !role.Valid() { + if !syncdomain.ValidMessageRole(message.Role) { return nil, fmt.Errorf("variation %q has unsupported message role %q", variation.Key, message.Role) } - prompt.Messages = append(prompt.Messages, adapters.Message{Role: role, Content: message.Content}) } + prompt.Messages = variation.Messages default: return nil, fmt.Errorf("referenced prompt does not support variation mode %q", variation.Mode) } return adapter.Render(prompt) } -// adapterFor is the single registry for external prompt formats. New formats -// plug into sync by implementing adapters.Adapter and adding one case here. -func adapterFor(format string) (adapters.Adapter, error) { - switch format { - case PlainMarkdown: - return plain_markdown.Adapter{}, nil - default: - return nil, fmt.Errorf("unsupported referenced prompt format %q", format) - } +// atMostOneSystemMessage reports whether messages is empty or is one system message. +func atMostOneSystemMessage(messages []syncdomain.Message) bool { + return len(messages) == 0 || len(messages) == 1 && messages[0].Role == syncdomain.RoleSystem } diff --git a/internal/sync/reference/format_test.go b/internal/sync/reference/format_test.go index 3dfc61c4..b6c20620 100644 --- a/internal/sync/reference/format_test.go +++ b/internal/sync/reference/format_test.go @@ -6,7 +6,6 @@ import ( "github.com/stretchr/testify/require" syncdomain "github.com/launchdarkly/ldcli/internal/sync" - "github.com/launchdarkly/ldcli/internal/sync/reference/adapters" ) func TestPlainMarkdownAdapterReturnsCommonPromptDomain(t *testing.T) { @@ -16,7 +15,7 @@ func TestPlainMarkdownAdapterReturnsCommonPromptDomain(t *testing.T) { require.Empty(t, prompt.Mode) require.Empty(t, prompt.Key) require.Empty(t, prompt.Name) - require.Equal(t, []adapters.Message{{Role: adapters.RoleSystem, Content: "Be helpful."}}, prompt.Messages) + require.Equal(t, []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Be helpful."}}, prompt.Messages) } func TestPlainMarkdownRoundTrip(t *testing.T) { @@ -45,7 +44,7 @@ func TestPlainMarkdownRoundTrip(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - _, err := ApplyToVariation(PlainMarkdown, []byte("\nBe helpful.\n"), &test.variation) + err := ApplyToVariation(PlainMarkdown, []byte("\nBe helpful.\n"), &test.variation) require.NoError(t, err) test.assert(t, test.variation) @@ -73,7 +72,7 @@ func TestReferenceFormatRejectsUnknownFormat(t *testing.T) { var variation syncdomain.Variation err := ValidateFormat("anthropic-prompt") require.ErrorContains(t, err, "unsupported referenced prompt format") - _, err = ApplyToVariation("anthropic-prompt", nil, &variation) + err = ApplyToVariation("anthropic-prompt", nil, &variation) require.ErrorContains(t, err, "unsupported referenced prompt format") _, err = Render("anthropic-prompt", variation) require.ErrorContains(t, err, "unsupported referenced prompt format") diff --git a/internal/sync/repository/git.go b/internal/sync/repository/git.go index b508eeb6..076a837a 100644 --- a/internal/sync/repository/git.go +++ b/internal/sync/repository/git.go @@ -34,9 +34,8 @@ func (execGit) lookPath(name string) (string, error) { func (execGit) output(dir string, args ...string) (string, string, error) { cmd := exec.Command("git", args...) cmd.Dir = dir - // Repository discovery recognizes Git's stable English "not a git - // repository" diagnostic. Fix the subprocess locale so classification does - // not change with the user's system language. + // Repository discovery looks for the English "not a git repository" + // message. A fixed locale keeps that message the same in every language. cmd.Env = append(os.Environ(), "LC_ALL=C") out, err := cmd.Output() diff --git a/internal/sync/resource.go b/internal/sync/resource.go index cb941278..5110133f 100644 --- a/internal/sync/resource.go +++ b/internal/sync/resource.go @@ -1,16 +1,18 @@ +// Package sync is the domain model that every prompt sync component shares. +// It defines resource identities, variations, attachments, and the +// fingerprints that the manifest stores. package sync import ( - "encoding/json" + "errors" "fmt" - "slices" "strings" ) -// RootDir is the repository-relative directory containing sync state. +// RootDir is the repository-relative directory that holds the sync files. const RootDir = ".launchdarkly" -// Kind identifies a synchronized resource type. +// Kind identifies the type of a synchronized resource. type Kind string const ( @@ -19,225 +21,88 @@ const ( KindSkill Kind = "skill" ) -// ResourceID uniquely identifies a synchronized resource. +// ResourceID identifies one synchronized resource. +// +// For a variation, LookupKey is "config-key/variation-key". For a tool or a +// skill, LookupKey is the attachment key. type ResourceID struct { Kind Kind ProjectKey string LookupKey string } -// ParseVariationSelector parses the stable project-key/config-key/variation-key identity -// accepted by non-interactive sync commands. -func ParseVariationSelector(selector string) (ResourceID, error) { - parts := strings.Split(selector, "/") - if len(parts) != 3 || slices.ContainsFunc(parts, func(part string) bool { - return part == "" || - part == "." || - part == ".." || - strings.ContainsAny(part, "\\\x00") - }) { - return ResourceID{}, fmt.Errorf( - "invalid variation %q; expected project-key/config-key/variation-key", - selector, - ) - } +// VariationID returns the identity of one config variation. +func VariationID(projectKey, configKey, variationKey string) ResourceID { return ResourceID{ Kind: KindVariation, - ProjectKey: parts[0], - LookupKey: parts[1] + "/" + parts[2], - }, nil -} - -// CompareResourceIDs orders resource identities for deterministic plans and output. -func CompareResourceIDs(left, right ResourceID) int { - if result := strings.Compare(string(left.Kind), string(right.Kind)); result != 0 { - return result - } - if result := strings.Compare(left.ProjectKey, right.ProjectKey); result != 0 { - return result + ProjectKey: projectKey, + LookupKey: configKey + "/" + variationKey, } - return strings.Compare(left.LookupKey, right.LookupKey) -} - -// SyncedResource contains one compiled local resource. -type SyncedResource struct { - Kind Kind - ProjectKey string - LookupKey string - Payload json.RawMessage - Attachments []Attachment - Upsert bool } -// VariationMode identifies how a prompt variation stores its content. -type VariationMode string - -const ( - VariationModeAgent VariationMode = "agent" - VariationModeCompletion VariationMode = "completion" -) - -// Valid reports whether the mode is supported for synchronized config variations. -func (mode VariationMode) Valid() bool { - switch mode { - case VariationModeAgent, VariationModeCompletion: - return true - default: - return false +// ParseVariationSelector parses the "project-key/config-key/variation-key" +// selector that the sync commands accept. +func ParseVariationSelector(selector string) (ResourceID, error) { + parts := strings.Split(selector, "/") + if len(parts) != 3 || ValidateKey(parts[0]) != nil || ValidateKey(parts[1]) != nil || ValidateKey(parts[2]) != nil { + return ResourceID{}, fmt.Errorf("invalid variation %q; expected project-key/config-key/variation-key", selector) } + return VariationID(parts[0], parts[1], parts[2]), nil } -// Message is one role/content pair in a completion prompt. -type Message struct { - Role string `json:"role"` - Content string `json:"content"` -} - -// AttachmentKind identifies a versioned resource referenced by a variation. -type AttachmentKind string - -const ( - AttachmentTool AttachmentKind = "tool" - AttachmentSkill AttachmentKind = "skill" -) - -// AttachmentRef is the stable local reference and exact API pin for one -// variation attachment. Local files persist only the key. -type AttachmentRef struct { - Key string `json:"key" yaml:"key"` - Version int `json:"version,omitempty" yaml:"-"` -} - -// Tool is the canonical, version-independent content of an AI tool. -type Tool struct { - Key string `json:"key" yaml:"key"` - Description *string `json:"description,omitempty" yaml:"description,omitempty"` - Schema map[string]any `json:"schema" yaml:"schema"` - CustomParameters map[string]any `json:"customParameters,omitempty" yaml:"customParameters,omitempty"` - Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` -} - -// Skill carries one Markdown file plus catalog metadata used for display. -// Local synchronization owns Key, Description, and Markdown; Name remains -// server-owned. -type Skill struct { - Key string `json:"key"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Markdown string `json:"markdown"` +// String returns the "project-key/lookup-key" form used in messages. +func (id ResourceID) String() string { + return id.ProjectKey + "/" + id.LookupKey } -// Attachment pairs canonical content with the latest version observed from -// LaunchDarkly. Exactly one type-specific payload is present. -type Attachment struct { - Kind AttachmentKind - Version int - Upsert bool - Tool *Tool - Skill *Skill -} - -// Key returns the stable key of the type-specific attachment payload. -func (attachment Attachment) Key() string { - switch attachment.Kind { - case AttachmentTool: - if attachment.Tool != nil { - return attachment.Tool.Key - } - case AttachmentSkill: - if attachment.Skill != nil { - return attachment.Skill.Key - } +// VariationKeys splits a variation lookup key into its config key and its +// variation key. +func (id ResourceID) VariationKeys() (configKey, variationKey string, err error) { + configKey, variationKey, ok := strings.Cut(id.LookupKey, "/") + if id.Kind != KindVariation || !ok || configKey == "" || variationKey == "" || strings.Contains(variationKey, "/") { + return "", "", fmt.Errorf("invalid variation lookup key %q", id.LookupKey) } - return "" -} - -// NormalizePromptText gives semantically equivalent prompt text one stable -// representation across API responses, local files, and operating systems. -func NormalizePromptText(content string) string { - content = strings.ReplaceAll(content, "\r\n", "\n") - content = strings.ReplaceAll(content, "\r", "\n") - return strings.TrimSpace(content) -} - -// Variation is the common prompt variation representation used by sync. -type Variation struct { - Mode VariationMode `json:"mode" yaml:"mode"` - Key string `json:"key" yaml:"key"` - Name string `json:"name" yaml:"name"` - Instructions string `json:"instructions,omitempty" yaml:"-"` - ModelConfigKey string `json:"modelConfigKey,omitempty" yaml:"modelConfigKey,omitempty"` - ModelConfigVersion int `json:"modelConfigVersion,omitempty" yaml:"modelConfigVersion,omitempty"` - Model map[string]any `json:"model,omitempty" yaml:"model,omitempty"` - OutputFormat map[string]any `json:"outputFormat,omitempty" yaml:"outputFormat,omitempty"` - Messages []Message `json:"messages,omitempty" yaml:"-"` - Tools []AttachmentRef `json:"tools,omitempty" yaml:"tools,omitempty"` - Skills []AttachmentRef `json:"skills,omitempty" yaml:"skills,omitempty"` - Attachments []Attachment `json:"-" yaml:"-"` + return configKey, variationKey, nil } -// NormalizeAttachments validates, sorts, and de-duplicates attachment keys so -// local ordering never produces fingerprint drift. -func (variation *Variation) NormalizeAttachments() error { - if err := normalizeAttachmentRefs(AttachmentTool, variation.Tools); err != nil { - return err - } - if err := normalizeAttachmentRefs(AttachmentSkill, variation.Skills); err != nil { - return err - } - if err := validateAttachmentMode(*variation); err != nil { - return err +// CompareResourceIDs orders identities by kind, project, and lookup key. Plans, +// manifests, and output use this order so that results are deterministic. +func CompareResourceIDs(left, right ResourceID) int { + if result := strings.Compare(string(left.Kind), string(right.Kind)); result != 0 { + return result } - - slices.SortFunc(variation.Tools, func(a, b AttachmentRef) int { return strings.Compare(a.Key, b.Key) }) - slices.SortFunc(variation.Skills, func(a, b AttachmentRef) int { return strings.Compare(a.Key, b.Key) }) - slices.SortFunc(variation.Attachments, func(a, b Attachment) int { - if result := strings.Compare(string(a.Kind), string(b.Kind)); result != 0 { - return result - } - return strings.Compare(a.Key(), b.Key()) - }) - return nil -} - -// Attachment returns canonical content and the latest observed version for one reference. -func (variation Variation) Attachment(kind AttachmentKind, key string) (Attachment, bool) { - for _, attachment := range variation.Attachments { - if attachment.Kind == kind && attachment.Key() == key { - return attachment, true - } + if result := strings.Compare(left.ProjectKey, right.ProjectKey); result != 0 { + return result } - return Attachment{}, false + return strings.Compare(left.LookupKey, right.LookupKey) } -// SetAttachment inserts or replaces one canonical dependency. -func (variation *Variation) SetAttachment(attachment Attachment) { - for index := range variation.Attachments { - if variation.Attachments[index].Kind == attachment.Kind && variation.Attachments[index].Key() == attachment.Key() { - variation.Attachments[index] = attachment - return - } +// ValidateKey makes sure that a project, config, variation, or attachment key +// is one safe path segment. Every key becomes part of a local file path. +func ValidateKey(key string) error { + switch { + case key == "": + return errors.New("must not be empty") + case key == "." || key == ".." || strings.ContainsAny(key, `/\`): + return errors.New("must be a single path segment") + case strings.IndexByte(key, 0) >= 0: + return errors.New("must not contain a null byte") + default: + return nil } - variation.Attachments = append(variation.Attachments, attachment) } -func normalizeAttachmentRefs(kind AttachmentKind, refs []AttachmentRef) error { - seen := make(map[string]struct{}, len(refs)) - for _, ref := range refs { - if strings.TrimSpace(ref.Key) == "" { - return fmt.Errorf("%s key is required", kind) - } - if _, duplicate := seen[ref.Key]; duplicate { - return fmt.Errorf("%s key %q is duplicated", kind, ref.Key) - } - seen[ref.Key] = struct{}{} - } - return nil +// SyncedResource is one variation compiled from the local workspace. +type SyncedResource struct { + Kind Kind + ProjectKey string + LookupKey string + // Upsert lets sync create the variation in LaunchDarkly when it is absent. + Upsert bool + Variation Variation } -func validateAttachmentMode(variation Variation) error { - if variation.Mode == VariationModeCompletion && len(variation.Skills) != 0 { - return fmt.Errorf("skills can only be attached to agent-mode configs") - } - return nil +// ID returns the identity of the compiled resource. +func (resource SyncedResource) ID() ResourceID { + return ResourceID{Kind: resource.Kind, ProjectKey: resource.ProjectKey, LookupKey: resource.LookupKey} } diff --git a/internal/sync/variation.go b/internal/sync/variation.go new file mode 100644 index 00000000..f3bea71d --- /dev/null +++ b/internal/sync/variation.go @@ -0,0 +1,221 @@ +package sync + +import ( + "errors" + "fmt" + "slices" + "strings" +) + +// VariationMode identifies how a prompt variation stores its content. An agent +// variation has instructions. A completion variation has messages. +type VariationMode string + +const ( + VariationModeAgent VariationMode = "agent" + VariationModeCompletion VariationMode = "completion" +) + +// Valid reports whether sync supports the mode. +func (mode VariationMode) Valid() bool { + return mode == VariationModeAgent || mode == VariationModeCompletion +} + +// Message is one role and content pair in a completion prompt. +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// The roles that a completion message can use. +const ( + RoleSystem = "system" + RoleUser = "user" + RoleAssistant = "assistant" +) + +// MessageRoles lists the roles that a completion message can use. +var MessageRoles = []string{RoleSystem, RoleUser, RoleAssistant} + +// ValidMessageRole reports whether a completion message can use the role. +func ValidMessageRole(role string) bool { + return slices.Contains(MessageRoles, role) +} + +// NormalizePromptText gives equivalent prompt text one representation across +// API responses, local files, and operating systems. +func NormalizePromptText(content string) string { + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\r", "\n") + return strings.TrimSpace(content) +} + +// Variation is the prompt variation model that every sync component uses. +// +// The JSON encoding of this struct is part of the fingerprint. Do not rename a +// field or change a JSON tag, because each change invalidates every manifest. +type Variation struct { + Mode VariationMode `json:"mode" yaml:"mode"` + Key string `json:"key" yaml:"key"` + Name string `json:"name" yaml:"name"` + Instructions string `json:"instructions,omitempty" yaml:"-"` + ModelConfigKey string `json:"modelConfigKey,omitempty" yaml:"modelConfigKey,omitempty"` + ModelConfigVersion int `json:"modelConfigVersion,omitempty" yaml:"modelConfigVersion,omitempty"` + Model map[string]any `json:"model,omitempty" yaml:"model,omitempty"` + OutputFormat map[string]any `json:"outputFormat,omitempty" yaml:"outputFormat,omitempty"` + Messages []Message `json:"messages,omitempty" yaml:"-"` + Tools []AttachmentRef `json:"tools,omitempty" yaml:"tools,omitempty"` + Skills []AttachmentRef `json:"skills,omitempty" yaml:"skills,omitempty"` + // Attachments holds the content of every tool and skill in Tools and Skills. + Attachments []Attachment `json:"-" yaml:"-"` +} + +// Validate makes sure that the variation has the fields that LaunchDarkly +// requires and that its attachment references are usable. +func (variation Variation) Validate() error { + switch { + case variation.Mode == "": + return errors.New("mode is required") + case !variation.Mode.Valid(): + return fmt.Errorf("unsupported mode %q", variation.Mode) + case variation.Key == "": + return errors.New("key is required") + case variation.Name == "": + return errors.New("name is required") + case variation.Mode != VariationModeAgent && len(variation.Skills) != 0: + return errors.New("skills can only be attached to agent-mode configs") + } + + for _, kind := range AttachmentKinds { + seen := make(map[string]struct{}) + for _, ref := range variation.Refs(kind) { + if strings.TrimSpace(ref.Key) == "" { + return fmt.Errorf("%s key is required", kind) + } + if _, duplicate := seen[ref.Key]; duplicate { + return fmt.Errorf("%s key %q is duplicated", kind, ref.Key) + } + seen[ref.Key] = struct{}{} + } + } + return nil +} + +// Refs returns the references of one attachment kind. +func (variation Variation) Refs(kind AttachmentKind) []AttachmentRef { + switch kind { + case AttachmentTool: + return variation.Tools + case AttachmentSkill: + return variation.Skills + default: + return nil + } +} + +// Attachment returns the content of one referenced attachment. +func (variation Variation) Attachment(kind AttachmentKind, key string) (Attachment, bool) { + index := variation.attachmentIndex(kind, key) + if index < 0 { + return Attachment{}, false + } + return variation.Attachments[index], true +} + +// SetAttachment inserts or replaces the content of one attachment. +func (variation *Variation) SetAttachment(attachment Attachment) { + if index := variation.attachmentIndex(attachment.Kind, attachment.Key()); index >= 0 { + variation.Attachments[index] = attachment + return + } + variation.Attachments = append(variation.Attachments, attachment) +} + +// Attach adds a reference to the attachment and stores its content. If the +// variation already references the key, Attach replaces only the content. +func (variation *Variation) Attach(attachment Attachment) { + refs := variation.refsPointer(attachment.Kind) + if refs != nil && !slices.ContainsFunc(*refs, func(ref AttachmentRef) bool { return ref.Key == attachment.Key() }) { + *refs = append(*refs, AttachmentRef{Key: attachment.Key()}) + } + variation.SetAttachment(attachment) +} + +// HydrateAttachments replaces Attachments with the content that read returns +// for each reference, and then sorts the references and the content. +func (variation *Variation) HydrateAttachments(read func(kind AttachmentKind, key string) (Attachment, error)) error { + attachments := make([]Attachment, 0, len(variation.Tools)+len(variation.Skills)) + for _, kind := range AttachmentKinds { + for _, ref := range variation.Refs(kind) { + attachment, err := read(kind, ref.Key) + if err != nil { + return err + } + attachments = append(attachments, attachment) + } + } + variation.Attachments = attachments + variation.SortAttachments() + return nil +} + +// SortAttachments puts references and content in key order, so that the local +// order of a list never changes a fingerprint or a rendered file. +func (variation *Variation) SortAttachments() { + byKey := func(left, right AttachmentRef) int { return strings.Compare(left.Key, right.Key) } + slices.SortFunc(variation.Tools, byKey) + slices.SortFunc(variation.Skills, byKey) + slices.SortFunc(variation.Attachments, func(left, right Attachment) int { + if result := strings.Compare(string(left.Kind), string(right.Kind)); result != 0 { + return result + } + return strings.Compare(left.Key(), right.Key()) + }) +} + +// PinAttachments returns a copy of the variation in which each reference uses +// the version that pin returns for its content. The original is not changed. +func (variation Variation) PinAttachments(pin func(Attachment) (int, error)) (Variation, error) { + variation.Tools = slices.Clone(variation.Tools) + variation.Skills = slices.Clone(variation.Skills) + for _, kind := range AttachmentKinds { + refs := *variation.refsPointer(kind) + for index := range refs { + attachment, ok := variation.Attachment(kind, refs[index].Key) + if !ok { + return Variation{}, fmt.Errorf("%s %q content is missing", kind, refs[index].Key) + } + version, err := pin(attachment) + if err != nil { + return Variation{}, err + } + refs[index].Version = version + } + } + return variation, nil +} + +// PinnedToLatest returns a copy of the variation in which each reference uses +// the version of its hydrated content. +func (variation Variation) PinnedToLatest() (Variation, error) { + return variation.PinAttachments(func(attachment Attachment) (int, error) { + return attachment.Version, nil + }) +} + +func (variation *Variation) refsPointer(kind AttachmentKind) *[]AttachmentRef { + switch kind { + case AttachmentTool: + return &variation.Tools + case AttachmentSkill: + return &variation.Skills + default: + return nil + } +} + +func (variation Variation) attachmentIndex(kind AttachmentKind, key string) int { + return slices.IndexFunc(variation.Attachments, func(attachment Attachment) bool { + return attachment.Kind == kind && attachment.Key() == key + }) +} From 890e40d1cbf5937d4a43b0364fe1e1e9518b0821 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Wed, 7 Oct 2026 23:39:05 -0400 Subject: [PATCH 3/4] fix(sync): reject unsynced detach selectors in an empty workspace An explicit detach selector skipped validation when no resource was synced. The command then reported success and did nothing. Detach now checks each named selector first, so a selector that is not synced always fails. The empty-workspace message stays for the interactive flow. --- internal/sync/detach/detach.go | 19 +++++++++++-------- internal/sync/detach/detach_test.go | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/internal/sync/detach/detach.go b/internal/sync/detach/detach.go index 29ff51a9..59e8c6a5 100644 --- a/internal/sync/detach/detach.go +++ b/internal/sync/detach/detach.go @@ -48,20 +48,23 @@ func Run(options Options) error { if err != nil { return err } + // Check a named selector before the empty-workspace message, so that a + // selector that is not synced always fails. console := syncconsole.New(options.Output) - if len(synced) == 0 { - _ = console.Line("No resources are currently synced.") - return nil - } - selected := options.Selections - if len(selected) == 0 { + if len(selected) != 0 { + if err := validateSelections(synced, selected); err != nil { + return err + } + } else { + if len(synced) == 0 { + _ = console.Line("No resources are currently synced.") + return nil + } var canceled bool if selected, canceled, err = promptForResources(options, synced); err != nil || canceled { return err } - } else if err := validateSelections(synced, selected); err != nil { - return err } if err := detachResources(options, manifest, selected); err != nil { return err diff --git a/internal/sync/detach/detach_test.go b/internal/sync/detach/detach_test.go index fbbcd257..2d739bc0 100644 --- a/internal/sync/detach/detach_test.go +++ b/internal/sync/detach/detach_test.go @@ -234,6 +234,24 @@ func TestRunReportsWhenNoResourcesAreSynced(t *testing.T) { assert.Equal(t, "No resources are currently synced.\n", output.String()) } +func TestRunRejectsExplicitSelectionWhenNoResourcesAreSynced(t *testing.T) { + root := t.TempDir() + var output bytes.Buffer + + err := Run(Options{ + RepositoryRoot: root, + Store: synclocal.NewStore(root), + Manifest: newMemoryManifestStore(), + Input: bytes.NewBuffer(nil), + Output: &output, + Selections: []syncdomain.ResourceID{syncdomain.VariationID("production", "support", "default")}, + NoInput: true, + }) + + require.ErrorContains(t, err, "variation production/support/default is not synced") + assert.Empty(t, output.String()) +} + type memoryManifestStore struct { manifest syncmanifest.Manifest loadedProjectKeys []string From 688c4e5900b23106aae182f48a27cb83c8846795 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Wed, 7 Oct 2026 23:42:22 -0400 Subject: [PATCH 4/4] fix(sync): accept attachment search results without a version The refactor sent each search result through the strict decoder, which requires a positive version. One result without a version then failed the whole page, and interactive attach could not list tools or skills. Search results now use a decoder that does not check the identity. A tool result still gets the strict check when the client reads the tool again for its latest version. --- internal/sync/api/attachment.go | 34 +++++++++++++++++----------- internal/sync/api/attachment_test.go | 23 +++++++++++++++++++ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/internal/sync/api/attachment.go b/internal/sync/api/attachment.go index 330d6eae..b75467a2 100644 --- a/internal/sync/api/attachment.go +++ b/internal/sync/api/attachment.go @@ -63,7 +63,8 @@ func (client Client) SearchAttachments( TotalCount: page.TotalCount, } for _, item := range page.Items { - attachment, err := decodeAttachment(kind, item) + // A search result can omit the version, so do not require it here. + attachment, err := parseAttachment(kind, item) if err != nil { return Page[syncdomain.Attachment]{}, err } @@ -162,31 +163,38 @@ func attachmentPath(projectKey string, kind syncdomain.AttachmentKind) ([]string } } -// decodeAttachment decodes one tool or skill response and checks its identity. +// decodeAttachment decodes the response of one tool or skill read. A read +// must return the key and a positive version. func decodeAttachment(kind syncdomain.AttachmentKind, data []byte) (syncdomain.Attachment, error) { - var attachment syncdomain.Attachment + attachment, err := parseAttachment(kind, data) + if err != nil { + return syncdomain.Attachment{}, err + } + switch { + case attachment.Key() == "": + return syncdomain.Attachment{}, fmt.Errorf("decode %s response: key is required", kind) + case attachment.Version < 1: + return syncdomain.Attachment{}, fmt.Errorf("decode %s response: version must be positive", kind) + } + return attachment, nil +} + +// parseAttachment decodes one tool or skill without checking its identity. +func parseAttachment(kind syncdomain.AttachmentKind, data []byte) (syncdomain.Attachment, error) { switch kind { case syncdomain.AttachmentTool: response, err := decodeJSON[toolResponse](data, "tool response") if err != nil { return syncdomain.Attachment{}, err } - attachment = syncdomain.Attachment{Kind: kind, Version: response.Version, Tool: &response.Tool} + return syncdomain.Attachment{Kind: kind, Version: response.Version, Tool: &response.Tool}, nil case syncdomain.AttachmentSkill: response, err := decodeJSON[skillResponse](data, "skill response") if err != nil { return syncdomain.Attachment{}, err } - attachment = syncdomain.Attachment{Kind: kind, Version: response.Version, Skill: &response.Skill} + return syncdomain.Attachment{Kind: kind, Version: response.Version, Skill: &response.Skill}, nil default: return syncdomain.Attachment{}, fmt.Errorf("unsupported attachment kind %q", kind) } - - switch { - case attachment.Key() == "": - return syncdomain.Attachment{}, fmt.Errorf("decode %s response: key is required", kind) - case attachment.Version < 1: - return syncdomain.Attachment{}, fmt.Errorf("decode %s response: version must be positive", kind) - } - return attachment, nil } diff --git a/internal/sync/api/attachment_test.go b/internal/sync/api/attachment_test.go index d070c847..91eea00f 100644 --- a/internal/sync/api/attachment_test.go +++ b/internal/sync/api/attachment_test.go @@ -66,6 +66,29 @@ func TestSearchAttachmentsDecodesLatestSkills(t *testing.T) { assert.Equal(t, "https://example.com/api/v2/projects/project/ai-configs/skills", transport.Requests[0].Path) } +func TestSearchAttachmentsAcceptsResultsWithoutVersion(t *testing.T) { + toolTransport := &recordingClient{Responses: [][]byte{ + []byte(`{"items": [{"key": "search", "schema": {"type": "object"}}], "totalCount": 1}`), + []byte(`{"key": "search", "schema": {"type": "object"}, "version": 4}`), + }} + tools, err := NewClient(toolTransport, "token", "https://example.com"). + SearchAttachments("project", syncdomain.AttachmentTool, "", 25, 0) + + require.NoError(t, err) + require.Len(t, tools.Items, 1) + assert.Equal(t, 4, tools.Items[0].Version) + + skillTransport := &recordingClient{Responses: [][]byte{ + []byte(`{"items": [{"key": "support", "name": "Support", "version": 0}], "totalCount": 1}`), + }} + skills, err := NewClient(skillTransport, "token", "https://example.com"). + SearchAttachments("project", syncdomain.AttachmentSkill, "", 25, 0) + + require.NoError(t, err) + require.Len(t, skills.Items, 1) + assert.Equal(t, "support", skills.Items[0].Key()) +} + func TestUpdateSkillSendsEditableFields(t *testing.T) { description := "Customer support guidance" markdown := "Help the customer.\n"