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..b75467a2 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,77 +34,61 @@ 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]{}, err + } + page, err := decodeJSON[Page[json.RawMessage]](response, string(kind)+" search response") if err != nil { - return Page[syncdomain.Attachment]{}, fmt.Errorf("search %ss: %w", kind, err) + 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 { + // 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 } - 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{}, fmt.Errorf("get %s %q: %w", kind, key, err) + return syncdomain.Attachment{}, err } attachment, err := decodeAttachment(kind, response) if err != nil { @@ -115,132 +100,101 @@ 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...) +} + +// 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) { + attachment, err := parseAttachment(kind, data) if err != nil { - return "", fmt.Errorf("build %s endpoint: %w", kind, err) + return syncdomain.Attachment{}, err } - return endpoint, nil + 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 } -func decodeAttachment(kind syncdomain.AttachmentKind, data []byte) (syncdomain.Attachment, error) { - var attachment syncdomain.Attachment +// 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: - 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} + return syncdomain.Attachment{Kind: kind, Version: response.Version, Tool: &response.Tool}, nil 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} + return syncdomain.Attachment{Kind: kind, Version: response.Version, Skill: &response.Skill}, nil default: return syncdomain.Attachment{}, fmt.Errorf("unsupported attachment kind %q", kind) } - if attachment.Key() == "" { - return syncdomain.Attachment{}, fmt.Errorf("decode %s response: key is required", kind) - } - if 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/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" diff --git a/internal/sync/api/catalog.go b/internal/sync/api/catalog.go index 585028d1..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,151 +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]{}, fmt.Errorf("search projects: %w", err) - } - - 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, fmt.Errorf("list model configs: %w", err) - } - - 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]{}, fmt.Errorf("search configs: %w", err) + 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 { @@ -187,54 +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{}, fmt.Errorf("get config %q: %w", configKey, err) + 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/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..eee528e9 100644 --- a/internal/sync/api/client.go +++ b/internal/sync/api/client.go @@ -1,434 +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" + "strconv" "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"` - 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"` - 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{}, fmt.Errorf("get sync manifest for project %q: %w", projectKey, err) - } - - 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{}, fmt.Errorf("get model config %q: %w", modelConfigKey, err) - } - - 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, - 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{} - } - request := updateVariationRequest{ - Name: variation.Name, - ModelConfigKey: variation.ModelConfigKey, - ModelConfigVersion: variation.ModelConfigVersion, - Model: model, - } - 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) - - return mutationError{ - err: err, - message: fmt.Sprintf("%s %s %q: %s", action, resource, key, err), - uncertain: !definitiveResponse, - } +// 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)}} } -func responseStatusCode(err error) (int, 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 - } +// 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) } - return 0, 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/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/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 2f3d0864..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,23 +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 @@ -42,61 +45,96 @@ type Options struct { Manifest ManifestStore Input io.Reader Output io.Writer - Initial bool - DryRun 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 interactively selects prompt variations and writes their local wrappers. +// Run writes the files of the selected variations and records their baseline. func Run(options Options) error { - if !syncinteractive.StreamsAreTerminal(options.Input, options.Output) { - return fmt.Errorf( - "interactive prompt selection requires a terminal; run this command in a terminal", - ) + var files []synclocal.VariationFile + var err error + if len(options.Selections) != 0 { + files, err = selectedVariations(options) + } else { + if err := syncinteractive.RequireTerminal( + options.Input, options.Output, options.NoInput, "variation selectors", "prompt selection", + ); err != nil { + return err + } + 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) } -// 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, - }) +// 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 { + configKey, variationKey, err := selection.VariationKeys() + if err != nil { + return nil, err + } + if _, duplicate := seen[selection]; duplicate { + return nil, fmt.Errorf("variation %s was selected more than once", selection) + } + seen[selection] = struct{}{} + + configID := selection.ProjectKey + "/" + configKey + config, ok := configs[configID] + if !ok { + if config, err = options.Catalog.Config(selection.ProjectKey, configKey); err != nil { + return nil, err + } + configs[configID] = config + } + index := slices.IndexFunc(config.Variations, func(variation syncdomain.Variation) bool { + return variation.Key == variationKey + }) + if index < 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 is already synced", selection) + } + + file, err := newVariationFile(options.Attachments, selection.ProjectKey, configKey, config.Variations[index]) + if err != nil { + return nil, err + } + files = append(files, file) + } + return files, nil +} + +// 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 } @@ -104,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" @@ -125,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 @@ -137,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, @@ -202,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 } @@ -245,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 { @@ -331,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 829a2c82..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" @@ -88,6 +87,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 @@ -227,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) @@ -361,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")}, }) @@ -386,7 +409,9 @@ second ) } -type fakeCatalog struct{} +type fakeCatalog struct { + config syncapi.Config +} var _ Catalog = &fakeCatalog{} @@ -398,6 +423,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..59e8c6a5 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,167 +15,169 @@ 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 + // 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 lets the user select resources and removes their local sync state. +// Run detaches the selected variations. func Run(options Options) error { - resources, manifest, err := loadResources(options.RepositoryRoot, options.Manifest, options.ProjectKeys) - if err != nil { - return err - } - if len(resources) == 0 { - _ = 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") + projectKeys := slices.Clone(options.ProjectKeys) + for _, selection := range options.Selections { + projectKeys = append(projectKeys, selection.ProjectKey) } + slices.Sort(projectKeys) + projectKeys = slices.Compact(projectKeys) - 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, - ) + synced, manifest, err := loadResources(options.RepositoryRoot, options.Manifest, projectKeys) if err != nil { return err } - if canceled { - return nil + // Check a named selector before the empty-workspace message, so that a + // selector that is not synced always fails. + console := syncconsole.New(options.Output) + selected := options.Selections + 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 + } } 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 } -// loadResources returns the union of local wrappers and manifested resources. +// 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 was selected more than once", selection) + } + seen[selection] = struct{}{} + if !slices.Contains(synced, selection) { + return fmt.Errorf("variation %s is not synced", selection) + } + } + return nil +} + +// 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{ @@ -183,18 +185,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 6d5ec356..2d739bc0 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) @@ -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 := syncdomain.ResourceID{ + 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: []syncdomain.ResourceID{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 @@ -205,15 +234,35 @@ 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 + 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..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,91 +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") - case len(variation.OutputFormat) != 0: - return fmt.Errorf("outputFormat is not supported by direct config variation APIs") +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 17c4f550..b0fdb6b0 100644 --- a/internal/sync/fingerprint_test.go +++ b/internal/sync/fingerprint_test.go @@ -283,23 +283,29 @@ func TestFingerprintAttachmentTracksCanonicalContentOnly(t *testing.T) { require.NotEqual(t, originalSkill, updatedSkill) } -func TestValidateDirectAPIVariationSupportsModelConfigVersion(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.ErrorContains(t, ValidateDirectAPIVariation(withOutput), "outputFormat") + require.NoError(t, withOutput.Validate()) + + 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) { +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 60bdf034..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,14 +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 @@ -38,9 +43,21 @@ type Options struct { Format string Input io.Reader Output io.Writer + // Target is the new variation. If it is nil, Run asks the user. + Target *Target + NoInput bool } -// Selection is the LaunchDarkly destination selected for a linked prompt. +// 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 + Name string + Content string +} + +// Selection is the complete destination of a new linked variation. type Selection struct { Project syncapi.Project Config syncapi.Config @@ -49,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 @@ -56,158 +75,175 @@ type linkedPrompt struct { content []byte } -// Run interactively selects 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) { - 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 } - prompt, err := readLinkedPrompt(options) + 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 + } + } if err != nil { return "", err } - 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 { + prompt, err = addMissingPromptContent(prompt, selection.Config.Mode, selection.Key, selection.Name, content) + if err != nil { return "", err } + return createLinkedPrompt(options, selection, prompt) +} - 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, +// 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) + } + 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 + } + modelConfigs, err := options.Catalog.ModelConfigs(projectKey) + if err != nil { + 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) + } + + name := cmp.Or(prompt.parsed.Name, target.Name, displayName(variationKey)) + return Selection{ + Project: syncapi.Project{Key: projectKey}, + Config: config, + ModelConfig: modelConfigs[index], + Key: variationKey, + Name: name, + }, target.Content, nil +} + +// 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 "", err + return Selection{}, "", canceled, err + } + var modes []syncdomain.VariationMode + if prompt.parsed.Mode != "" { + modes = []syncdomain.VariationMode{prompt.parsed.Mode} + } + config, canceled, err := syncinteractive.SelectConfig(options.Input, options.Output, options.Catalog, project.Key, modes) + if err != nil || canceled { + return Selection{}, "", canceled, err } - _ = console.Line("Loading model configs...") + _ = syncconsole.New(options.Output).Line("Loading model configs...") modelConfigs, err := options.Catalog.ModelConfigs(project.Key) if err != nil { - return "", err + return Selection{}, "", false, err + } + 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, + }) } - modelConfig, canceled, err := syncinteractive.Select( - options.Input, - options.Output, - "Choose a model config", - modelConfigChoices(modelConfigs), - ) + modelConfig, canceled, err := syncinteractive.Select(options.Input, options.Output, "Choose a model config", choices) if err != nil || canceled { - return "", err + return Selection{}, "", canceled, err } - defaultKey := strings.TrimSuffix(filepath.Base(prompt.reference.File), filepath.Ext(prompt.reference.File)) - key := prompt.parsed.Key + // 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 = defaultKey - fields = append(fields, huh.NewInput(). - Title("Variation key"). - Value(&key). - Validate(validateDerivedKey)) + 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)) } - name := prompt.parsed.Name if name == "" { name = displayName(key) - fields = append(fields, huh.NewInput(). - Title("Variation name"). - Value(&name). - Validate(requiredValue("variation name"))) + 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). + fields = append(fields, huh.NewText().Title("Prompt content").Lines(8).Value(&content). Validate(requiredValue("prompt content"))) } if len(fields) != 0 { - canceled, err = syncinteractive.RunForm(options.Input, options.Output, fields...) - if err != nil || canceled { - return "", err + if canceled, err := syncinteractive.RunForm(options.Input, options.Output, fields...); err != nil || canceled { + return Selection{}, "", canceled, err } } - prompt, err = addMissingPromptContent(prompt, config.Mode, key, name, content) - if err != nil { - return "", err - } - - return createLinkedPrompt(options, Selection{ - Project: project, - Config: config, - ModelConfig: modelConfig, - Key: key, - 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, @@ -217,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 { @@ -242,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, @@ -268,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 { @@ -300,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) == "" { @@ -322,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 dbe1a66a..675703e6 100644 --- a/internal/sync/link/link_test.go +++ b/internal/sync/link/link_test.go @@ -13,12 +13,76 @@ import ( syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" ) -func TestCreateWritesLinkedVariationWrapper(t *testing.T) { +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 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, @@ -62,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, @@ -90,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)) @@ -158,3 +222,32 @@ 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 +} + +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 3693e84c..6a3f923f 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", ) @@ -279,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) @@ -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", ) @@ -353,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) { @@ -559,12 +597,51 @@ 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) } +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) + pulled := resources[0].Variation + 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 +810,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) } @@ -1131,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 aeea6c1f..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("--project 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,22 +119,70 @@ 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) } 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) } + 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("--variation is required without a terminal") } - 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 ebcf15bb..3abd0ac5 100644 --- a/internal/sync/prompt/conflict.go +++ b/internal/sync/prompt/conflict.go @@ -2,43 +2,99 @@ 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" ) -type conflictResolution string +// ConflictResolution is the side that wins a conflict. +type ConflictResolution string const ( - useLaunchDarkly conflictResolution = "launchdarkly" - useLocal conflictResolution = "local" - abortConflict conflictResolution = "abort" + // ConflictUseLaunchDarkly writes the LaunchDarkly state to the local file. + ConflictUseLaunchDarkly ConflictResolution = "launchdarkly" + // ConflictUseLocal writes the local state to LaunchDarkly. + ConflictUseLocal ConflictResolution = "local" + // ConflictAbort stops the sync before it writes anything. + ConflictAbort ConflictResolution = "abort" ) +// 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 +} + +// 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 { - resolutions map[ResourceID]conflictResolution + resolutions map[ResourceID]ConflictResolution aborted bool sourcesChanged bool } type conflictChoice struct { - resolution conflictResolution + 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, @@ -46,49 +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 } - 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)} - 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]) + result := conflictResolutionResult{resolutions: make(map[ResourceID]ConflictResolution)} + for _, group := range groups { + if err := writeConflict(options.ErrorOutput, group); err != nil { + return conflictResolutionResult{}, err } - conflict := Plan{Resources: visible} - if err := writePlanReview(options.ErrorOutput, "plaintext", conflict, terminalWidth(options.ErrorOutput)); err != nil { + + resolution, fromPolicy, err := options.ConflictPolicy.resolve(group) + if 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) + var choice conflictChoice + switch { + case fromPolicy: + choice = conflictChoice{resolution: resolution, aborted: resolution == ConflictAbort} + writeConflictChoice(options.ErrorOutput, choice) + if choice.aborted { + return conflictResolutionResult{}, errConflictAborted } + 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) } - - choice, err := readConflictChoice(options, reader, watched) if err != nil { return conflictResolutionResult{}, err } - if choice.sourcesChanged { - result.sourcesChanged = true - return result, nil - } - 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 { @@ -98,31 +149,53 @@ func resolveConflicts( return result, nil } -// groupConflicts presents one choice for each connected set of variations that -// share a changed dependency, including conflicts with other local edits. +// 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 err := writePlanReview(output, "plaintext", Plan{Resources: visible}, terminalWidth(output)); err != nil { + return err + } + 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) + } + } + return nil +} + +// 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) @@ -130,52 +203,37 @@ 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 + return slices.ContainsFunc(left.changedAttachments, func(id ResourceID) bool { + return slices.Contains(right.changedAttachments, id) + }) } -// 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) -} - -// 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, 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 { return conflictChoice{}, err } - choice := conflictChoice{ - resolution: resolution, - aborted: canceled || resolution == abortConflict, - } + 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, @@ -185,105 +243,78 @@ 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 { 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.") } } -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. -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...)} +// 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 { + 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. -func resolvedConflictAction(resource PlannedResource, resolution conflictResolution) Action { +// resolvedConflictAction returns the action that makes the chosen side win. +func resolvedConflictAction(resource PlannedResource, resolution ConflictResolution) Action { switch resolution { - case useLaunchDarkly: + case ConflictUseLaunchDarkly: if resource.Server == nil { return ActionDeleteLocal } return ActionUpdateLocal - case useLocal: - if resource.Local == nil { + case ConflictUseLocal: + 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 8c2b1460..f87b2f76 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) @@ -112,13 +112,13 @@ 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) { 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) { @@ -179,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}, }, } @@ -288,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.") } @@ -319,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 94% rename from internal/sync/prompt/terminal_test.go rename to internal/sync/prompt/review_test.go index 414494a3..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 != "" { @@ -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/runner.go b/internal/sync/prompt/runner.go index ee0002f3..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,519 +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" ) -// AttachmentRequest identifies one tool or skill to attach to a managed variation. -type AttachmentRequest struct { - Kind syncdomain.AttachmentKind - Key string - ProjectKey string - Variation string -} - -// 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 - Yes bool - Context context.Context - Input io.Reader - Output io.Writer - ErrorOutput io.Writer -} - -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 } - // 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), - } - - if options.Detach { - projectKeys, err := discoverProjectKeys(workspace.root) - if err != nil { + 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: + return runner.runDetach(options, workspace, action) + case LinkAction: + linked, err := runner.runLink(options, workspace, action) + if err != nil || !linked { return err } - return runner.detach(syncdetach.Options{ - RepositoryRoot: workspace.root, - Store: workspace.local, - Manifest: workspace.manifest, - ProjectKeys: projectKeys, - Input: options.Input, - Output: options.Output, - }) - } - if options.Link != "" { - path, err := runner.link(synclink.Options{ - Catalog: apiClient, - Store: workspace.local, - RepositoryRoot: workspace.root, - WorkingDirectory: options.WorkingDirectory, - File: options.Link, - Format: options.Format, - Input: options.Input, - Output: options.Output, - }) - if err != nil { + case AttachAction: + attached, err := runner.runAttach(options, workspace, action) + if err != nil || !attached { return err } - if path == "" { - return nil - } - _ = syncconsole.New(options.Output).Printf( - "Linked %s/%s.\n", - syncdomain.RootDir, - path, - ) + default: + return fmt.Errorf("unsupported prompt sync action %T", action) } - localDirectoryExists, err := workspace.local.Exists() + + // Link and attach change only local files. One sync applies the change. + return runner.runWorkspaceSync(options, workspace, nil) +} + +// 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 } - - if options.Attachment != nil { - if !localDirectoryExists { - return fmt.Errorf("attach a tool or skill after synchronizing at least one variation") - } - 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), - Input: options.Input, - Output: options.Output, - }); err != nil { - if errors.Is(err, errAttachmentCanceled) { - return nil - } - return err - } - } - projectKeys, err := discoverProjectKeys(workspace.root) if err != nil { return err } - if (!localDirectoryExists && len(projectKeys) == 0) || options.Add { - 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: options.DryRun, - }); err != nil { + if !exists && len(projectKeys) == 0 { + if err := runner.bootstrap(runner.bootstrapOptions(options, workspace, true, action.DryRun, nil)); err != nil { return err } - if !options.Watch { + if !action.Watch { return nil } - localDirectoryExists, err = workspace.local.Exists() - if err != nil { + if exists, err = workspace.local.Exists(); err != nil || !exists { return err } - if !localDirectoryExists { - return nil - } } - if options.Watch { - ctx := options.Context - if ctx == nil { - ctx = context.Background() - } - 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. - return runner.watch(ctx, workspace.root, watchDebounce, func(watcher *sourceWatcher) error { - return runner.runWorkspaceSync(syncOptions, workspace, watcher) - }, options.ErrorOutput) + if !action.Watch { + return runner.runWorkspaceSync(options, workspace, nil) } - - 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 = "" + ctx, stop := signal.NotifyContext(options.Context, os.Interrupt, syscall.SIGTERM) + defer stop() options.Context = ctx - return options + return runner.watch(ctx, workspace.root, watchDebounce, func(watcher *sourceWatcher) error { + return runner.runWorkspaceSync(options, workspace, watcher) + }, options.ErrorOutput) } -// 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) - if err != nil { - return err - } - reviewedPlan, _, err := loadWorkspacePlan(workspace.root, baseline, apiClient) - if err != nil { - return err - } - - 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) - 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.Watch { - return errRefreshWatchPlan - } - return fmt.Errorf("sync projects changed after review; run sync again") - } - currentManifest, err := workspace.manifest.Load(currentProjectKeys) +func (runner Runner) runAdd(options Options, workspace syncWorkspace, action AddAction) error { + exists, err := workspace.local.Exists() if err != nil { return err } - currentPlan, currentLocalFiles, err := loadWorkspacePlan(workspace.root, currentManifest, apiClient) - if err != nil { - return err - } - if !samePlanState(reviewedPlan, currentPlan) { - if options.Watch { - 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 { - 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) - } - return nil + return runner.bootstrap(runner.bootstrapOptions(options, workspace, !exists, action.DryRun, action.Variations)) } -// 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 - } - return BuildPlan(baseline, canonicalLocalResources, serverResources), localFilesByID, nil -} - -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 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 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)} -} - -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 8c93fea0..55aef41a 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, })) } @@ -298,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) } } @@ -326,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"}}, @@ -334,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 9fda9fa1..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.Watch && !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 b9243f84..0717b5ad 100644 --- a/internal/sync/prompt/watch.go +++ b/internal/sync/prompt/watch.go @@ -84,10 +84,12 @@ 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 - // 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 } @@ -120,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 @@ -162,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 @@ -256,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 @@ -280,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) } @@ -289,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 @@ -381,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/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/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 b646ede2..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,203 +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 } -// 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 +// VariationID returns the identity of one config variation. +func VariationID(projectKey, configKey, variationKey string) ResourceID { + return ResourceID{ + Kind: KindVariation, + ProjectKey: projectKey, + LookupKey: configKey + "/" + variationKey, } - if result := strings.Compare(left.ProjectKey, right.ProjectKey); result != 0 { - return result - } - 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 + }) +}