diff --git a/cmd/sync/attachments.go b/cmd/sync/attachments.go index 827c9cf0..75e8e1a1 100644 --- a/cmd/sync/attachments.go +++ b/cmd/sync/attachments.go @@ -62,11 +62,64 @@ func newAttachmentScopeCmd(client resources.Client, kind syncdomain.AttachmentKi newWatchCmd(client, scope, help), newAttachmentAddCmd(client, kind), newAttachmentDetachCmd(client, kind), + newAttachmentLinkCmd(client, kind), ) cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) return cmd } +func newAttachmentLinkCmd(client resources.Client, kind syncdomain.AttachmentKind) *cobra.Command { + scope := syncdomain.Scope(kind + "s") + cmd := &cobra.Command{ + Use: "link", + Short: fmt.Sprintf("Link a repository source to one %s", kind), + Example: fmt.Sprintf(` ldcli sync %[1]ss link \ + --file src/resources.ts \ + --format typescript \ + --select '%[1]ss[id="example"]' \ + --map description=description \ + --project production --key example \ + --yes --no-input`, kind), + Args: validatedArgs(cobra.NoArgs), + RunE: runCommand(client, scope, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) { + id, err := sourceLinkID(cmd, syncdomain.Kind(kind)) + if err != nil { + return nil, err + } + fieldMap, err := parseFieldMappings(mustStringArray(cmd, mapFlag)) + if err != nil { + return nil, err + } + return syncprompt.SourceLinkAction{ + ID: id, + File: mustString(cmd, fileFlag), + Format: mustString(cmd, formatFlag), + Select: mustString(cmd, selectFlag), + Map: fieldMap, + }, nil + }), + } + addTargetFlags(cmd, attachmentFlagHelp(kind, false)) + cmd.Flags().String(fileFlag, "", "Repository source file") + cmd.Flags().String(formatFlag, "", "Source format") + cmd.Flags().String(selectFlag, "", "Source value to select") + cmd.Flags().StringArray(mapFlag, nil, "Mapped field in field=path form. Repeat for more fields.") + _ = cmd.MarkFlagRequired(fileFlag) + _ = cmd.MarkFlagRequired(formatFlag) + addApplyFlags(cmd, scope, false) + return cmd +} + +func mustString(cmd *cobra.Command, name string) string { + value, _ := cmd.Flags().GetString(name) + return value +} + +func mustStringArray(cmd *cobra.Command, name string) []string { + value, _ := cmd.Flags().GetStringArray(name) + return value +} + // attachmentScopeLong is the description of sync tools or sync skills. func attachmentScopeLong(kind syncdomain.AttachmentKind) string { upsert := `set "upsert": true in its file` diff --git a/cmd/sync/prompt.go b/cmd/sync/prompt.go index 34d73fb2..2e599df8 100644 --- a/cmd/sync/prompt.go +++ b/cmd/sync/prompt.go @@ -1,6 +1,8 @@ package sync import ( + "encoding/json" + "errors" "fmt" "os" "strings" @@ -12,6 +14,7 @@ import ( "github.com/launchdarkly/ldcli/cmd/cliflags" resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" "github.com/launchdarkly/ldcli/cmd/validators" + lderrors "github.com/launchdarkly/ldcli/internal/errors" "github.com/launchdarkly/ldcli/internal/output" "github.com/launchdarkly/ldcli/internal/resources" syncdomain "github.com/launchdarkly/ldcli/internal/sync" @@ -26,6 +29,7 @@ const ( descriptionFlag = "description" diffFlag = "diff" dryRunFlag = "dry-run" + fileFlag = "file" formatFlag = "format" jsonSchemaFlag = "json-schema" keyFlag = "key" @@ -33,6 +37,8 @@ const ( nameFlag = "name" noInputFlag = "no-input" resolveFlag = "resolve" + selectFlag = "select" + mapFlag = "map" toFlag = "to" yesFlag = "yes" ) @@ -67,6 +73,7 @@ func NewPromptCmd(client resources.Client) *cobra.Command { newAddCmd(client), newAttachCmd(client), newDetachCmd(client), + newLinkCmd(client), ) cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) @@ -119,12 +126,22 @@ func runCommand(client resources.Client, scope syncdomain.Scope, buildAction act ErrorOutput: cmd.ErrOrStderr(), }) if err != nil { - return output.NewCmdOutputError(err, outputKind) + return syncCommandOutputError(err, outputKind) } return nil } } +func syncCommandOutputError(err error, outputKind string) error { + if problem, ok := syncdomain.ProblemFrom(err); ok { + data, marshalErr := json.Marshal(problem) + if marshalErr == nil { + err = lderrors.NewError(string(data)) + } + } + return output.NewCmdOutputError(err, outputKind) +} + // newWatchCmd creates the watch command of a scope. help is the help text of // the target flags of the root sync of the scope. func newWatchCmd(client resources.Client, scope syncdomain.Scope, help targetFlagHelp) *cobra.Command { @@ -268,6 +285,83 @@ func newDetachCmd(client resources.Client) *cobra.Command { return cmd } +func newLinkCmd(client resources.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "link", + Short: "Link a repository source to one prompt variation", + Example: ` # Link one Python agent + ldcli sync prompts link \ + --file app/agents.py \ + --format python \ + --select 'agents[id="support"]' \ + --map prompt=instructions \ + --project production --config-key support --key default \ + --yes --no-input`, + Args: validatedArgs(cobra.NoArgs), + RunE: runCommand(client, syncdomain.ScopePrompts, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) { + format, _ := cmd.Flags().GetString(formatFlag) + sourceFile, _ := cmd.Flags().GetString(fileFlag) + selectPath, _ := cmd.Flags().GetString(selectFlag) + mappings, _ := cmd.Flags().GetStringArray(mapFlag) + id, err := sourceLinkID(cmd, syncdomain.KindVariation) + if err != nil { + return nil, err + } + fieldMap, err := parseFieldMappings(mappings) + if err != nil { + return nil, err + } + return syncprompt.SourceLinkAction{ + ID: id, File: sourceFile, Format: format, Select: selectPath, Map: fieldMap, + }, nil + }), + } + addTargetFlags(cmd, questionFlagHelp) + cmd.Flags().String(fileFlag, "", "Repository source file") + cmd.Flags().String(formatFlag, "", "Source format") + cmd.Flags().String(selectFlag, "", "Source value to select") + cmd.Flags().StringArray(mapFlag, nil, "Mapped field in field=path form. Repeat for more fields.") + _ = cmd.MarkFlagRequired(fileFlag) + _ = cmd.MarkFlagRequired(formatFlag) + addApplyFlags(cmd, syncdomain.ScopePrompts, false) + return cmd +} + +func sourceLinkID(cmd *cobra.Command, kind syncdomain.Kind) (syncdomain.ResourceID, error) { + filter := targetFilter(cmd) + if filter.Project == "" { + return syncdomain.ResourceID{}, errors.New("--project is required") + } + if len(filter.Keys) != 1 { + return syncdomain.ResourceID{}, errors.New("link requires exactly one --key") + } + if kind == syncdomain.KindVariation { + if filter.ConfigKey == "" { + return syncdomain.ResourceID{}, errors.New("--config-key is required") + } + return syncdomain.VariationID(filter.Project, filter.ConfigKey, filter.Keys[0]), nil + } + return syncdomain.ResourceID{Kind: kind, ProjectKey: filter.Project, LookupKey: filter.Keys[0]}, nil +} + +func parseFieldMappings(values []string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + fieldMap := make(map[string]string, len(values)) + for _, value := range values { + field, path, ok := strings.Cut(value, "=") + if !ok || field == "" || path == "" { + return nil, fmt.Errorf("invalid --map %q; expected field=path", value) + } + if _, duplicate := fieldMap[field]; duplicate { + return nil, fmt.Errorf("--map field %q was provided more than once", field) + } + fieldMap[field] = path + } + return fieldMap, nil +} + // addApplyFlags registers the flags of a command that applies a plan. Only // the prompts scope has --resolve, which names variations. func addApplyFlags(cmd *cobra.Command, scope syncdomain.Scope, dryRun bool) { diff --git a/cmd/sync/prompt_test.go b/cmd/sync/prompt_test.go index f3708811..ceb16645 100644 --- a/cmd/sync/prompt_test.go +++ b/cmd/sync/prompt_test.go @@ -17,17 +17,65 @@ func TestPromptCommandUsesActionSubcommands(t *testing.T) { command := NewPromptCmd(nil) assert.Equal(t, "prompts", command.Use) - for _, name := range []string{"watch", "add", "attach", "detach"} { + 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", "watch"} { + 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.Nil(t, link.Flags().Lookup(toFlag)) + assert.Nil(t, link.Flags().Lookup(modelConfigKeyFlag)) +} + +func TestSourceLinkFlagsBuildTargetAndMappings(t *testing.T) { + command := newLinkCmd(nil) + require.NoError(t, command.ParseFlags([]string{ + "--file", "agents.py", + "--format", "python", + "--select", `agents[id="support"]`, + "--map", "prompt=instructions", + "--project", "production", + "--config-key", "support", + "--key", "default", + })) + + id, err := sourceLinkID(command, syncdomain.KindVariation) + require.NoError(t, err) + assert.Equal(t, syncdomain.VariationID("production", "support", "default"), id) + mappings, err := parseFieldMappings(mustStringArray(command, mapFlag)) + require.NoError(t, err) + assert.Equal(t, map[string]string{"prompt": "instructions"}, mappings) +} + +func TestSyncProblemRendersStableFields(t *testing.T) { + problem := syncdomain.NewProblem( + syncdomain.ErrorPathNotFound, + "mapped field was not found", + "Update the map path.", + ).WithResource(syncdomain.VariationID("project", "config", "default")). + WithFile("agents.py", 4, 12) + + jsonError := syncCommandOutputError(problem, "json") + require.JSONEq(t, `{ + "code": "path_not_found", + "message": "mapped field was not found", + "resource": "variation project/config/default", + "file": "agents.py", + "line": 4, + "column": 12, + "suggestion": "Update the map path." + }`, jsonError.Error()) + + plainError := syncCommandOutputError(problem, "plaintext") + assert.Contains(t, plainError.Error(), "mapped field was not found (code: path_not_found)") + assert.Contains(t, plainError.Error(), "Suggestion: Update the map path.") } func TestSyncCommandIncludesExamples(t *testing.T) { @@ -53,6 +101,7 @@ func TestPromptCommandHelpIncludesExamples(t *testing.T) { "attach tool": {"attach", "tool"}, "attach skill": {"attach", "skill"}, "detach": {"detach"}, + "link": {"link"}, } for name, path := range tests { @@ -107,6 +156,7 @@ func TestPromptCommandsRegisterTargetFlags(t *testing.T) { "add": {path: []string{"add"}, help: questionFlagHelp}, "detach": {path: []string{"detach"}, help: questionFlagHelp}, "attach tool": {path: []string{"attach", "tool"}, help: targetFlagHelp{project: questionFlagHelp.project}}, + "link": {path: []string{"link"}, help: questionFlagHelp}, } for name, test := range tests { diff --git a/cmd/sync/scope_test.go b/cmd/sync/scope_test.go index 1d7a0ec9..954775d3 100644 --- a/cmd/sync/scope_test.go +++ b/cmd/sync/scope_test.go @@ -25,14 +25,20 @@ var syncCommandFlags = map[string][]string{ "sync prompts attach tool": {"conflict", "diff", "no-input", "project", "resolve", "to", "yes"}, "sync prompts attach skill": {"conflict", "diff", "no-input", "project", "resolve", "to", "yes"}, "sync prompts detach": {"archive", "config-key", "key", "no-input", "project", "yes"}, - "sync tools": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"}, - "sync tools watch": {"conflict", "diff", "key", "no-input", "project", "yes"}, - "sync tools add": {"description", "diff", "dry-run", "json-schema", "key", "no-input", "project", "yes"}, - "sync tools detach": {"key", "no-input", "project", "yes"}, - "sync skills": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"}, - "sync skills watch": {"conflict", "diff", "key", "no-input", "project", "yes"}, - "sync skills add": {"content", "description", "diff", "dry-run", "key", "name", "no-input", "project", "yes"}, - "sync skills detach": {"key", "no-input", "project", "yes"}, + "sync prompts link": { + "config-key", "conflict", "diff", "file", "format", "key", "map", + "no-input", "project", "resolve", "select", "yes", + }, + "sync tools": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"}, + "sync tools watch": {"conflict", "diff", "key", "no-input", "project", "yes"}, + "sync tools add": {"description", "diff", "dry-run", "json-schema", "key", "no-input", "project", "yes"}, + "sync tools detach": {"key", "no-input", "project", "yes"}, + "sync tools link": {"conflict", "diff", "file", "format", "key", "map", "no-input", "project", "select", "yes"}, + "sync skills": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"}, + "sync skills watch": {"conflict", "diff", "key", "no-input", "project", "yes"}, + "sync skills add": {"content", "description", "diff", "dry-run", "key", "name", "no-input", "project", "yes"}, + "sync skills detach": {"key", "no-input", "project", "yes"}, + "sync skills link": {"conflict", "diff", "file", "format", "key", "map", "no-input", "project", "select", "yes"}, } func TestSyncCommandsHaveExactFlags(t *testing.T) { @@ -65,14 +71,16 @@ func TestSyncCommandsReadSyncFlagsOnlyFromTheCommandLine(t *testing.T) { t.Setenv("LD_"+strings.ToUpper(strings.ReplaceAll(name, "-", "_")), "true") } for _, name := range []string{ - "config-key", "conflict", "content", "description", "format", "json-schema", "key", "model-config-key", "name", "resolve", "to", + "config-key", "conflict", "content", "description", "file", "format", "json-schema", "key", + "map", "model-config-key", "name", "resolve", "select", "to", } { t.Setenv("LD_"+strings.ToUpper(strings.ReplaceAll(name, "-", "_")), "from-environment") } for _, command := range runnableSyncCommands(NewSyncCmd(nil, nil)) { require.NoError(t, command.ParseFlags(nil)) - require.NoError(t, command.Args(command, nil), command.CommandPath()) + var args []string + require.NoError(t, command.Args(command, args), command.CommandPath()) command.LocalNonPersistentFlags().VisitAll(func(flag *pflag.Flag) { assert.False(t, flag.Changed, "%s --%s", command.CommandPath(), flag.Name) }) diff --git a/internal/sync/prompt/acceptance_test.go b/internal/sync/prompt/acceptance_test.go index f05d82ad..8e4089f9 100644 --- a/internal/sync/prompt/acceptance_test.go +++ b/internal/sync/prompt/acceptance_test.go @@ -39,6 +39,7 @@ type directAPI struct { tools map[string]versionedTool skills map[string]versionedSkill manifest *syncapi.SyncManifest + directions map[string]syncdomain.SyncDirection requests []string // expandTools makes each read of the variation add the content of its // tools to model.parameters.tools, as LaunchDarkly does. @@ -99,6 +100,20 @@ func (api *directAPI) MakeRequest( if strings.HasSuffix(path, "/configs/sync/manifests") { return handleManifestRequest(api.manifest, method, query, body) } + if strings.HasSuffix(path, "/configs/sync/settings") && method == http.MethodGet { + items := make([]map[string]any, 0, len(query["resources"])) + for _, resource := range query["resources"] { + kind, lookupKey, _ := strings.Cut(resource, ":") + direction := syncdomain.SyncDirectionBoth + if configured := api.directions[resource]; configured != "" { + direction = configured + } + items = append(items, map[string]any{ + "resourceKind": kind, "resourceLookupKey": lookupKey, "direction": direction, + }) + } + return json.Marshal(map[string]any{"items": items}) + } isCreate := method == http.MethodPost && (strings.HasSuffix(path, "/ai-tools") || strings.HasSuffix(path, "/ai-configs/skills") || strings.HasSuffix(path, "/variations")) if isCreate && api.createErr != nil { @@ -218,6 +233,7 @@ func (api *directAPI) MakeRequest( ModelConfigVersion int `json:"modelConfigVersion"` Model map[string]any `json:"model"` OutputFormat map[string]any `json:"outputFormat"` + Messages *[]syncdomain.Message `json:"messages"` Tools *[]syncdomain.AttachmentRef `json:"tools"` Skills *[]syncdomain.AttachmentRef `json:"skills"` } @@ -238,12 +254,16 @@ func (api *directAPI) MakeRequest( if update.Skills != nil { skills = *update.Skills } + mode := cmp.Or(api.configMode, syncdomain.VariationModeAgent) api.variation = &syncdomain.Variation{ - Mode: syncdomain.VariationModeAgent, Key: key, Name: update.Name, + Mode: mode, Key: key, Name: update.Name, Instructions: update.Instructions, ModelConfigKey: update.ModelConfigKey, ModelConfigVersion: update.ModelConfigVersion, Model: update.Model, OutputFormat: update.OutputFormat, Tools: tools, Skills: skills, } + if update.Messages != nil { + api.variation.Messages = *update.Messages + } api.variationState = "published" } return []byte(`{}`), nil @@ -1945,6 +1965,556 @@ func runSyncAction( return stdout, err } +func TestSourceLinkUsesDirectionAndKeepsSourceInPlace(t *testing.T) { + root := initRepository(t) + source := []byte(`from agents import Agent +support = Agent(instructions="Local instructions.") +`) + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), source, 0o644)) + server := variation("Server instructions.") + api := &directAPI{ + variation: &server, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionCodeCanonical, + }, + } + id := syncdomain.VariationID("production", "support", "default") + + output, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "agents.py", Format: "python", Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, + }, + Yes: true, + }) + + require.NoError(t, err) + assert.Contains(t, output, `"action": "update_server"`) + require.NotNil(t, api.variation) + assert.Equal(t, "Local instructions.", api.variation.Instructions) + descriptor := filepath.Join(root, ".launchdarkly", "production", "configs", "support", "default.ref.yaml") + require.FileExists(t, descriptor) + actual, err := os.ReadFile(filepath.Join(root, "agents.py")) + require.NoError(t, err) + assert.Equal(t, source, actual) + lock, err := os.ReadFile(filepath.Join(root, ".launchdarkly", "sync.lock")) + require.NoError(t, err) + assert.Contains(t, string(lock), "formatVersion: 1") + assert.NotContains(t, string(lock), "file: agents.py") + + api.directions["variation:support/default"] = syncdomain.SyncDirectionServerCanonical + api.variation.Instructions = "Server instructions after link." + output, _, err = runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SyncAction{Filter: syncdomain.Filter{ + Project: "production", ConfigKey: "support", Keys: []string{"default"}, + }}, + Yes: true, + }) + require.NoError(t, err) + assert.Contains(t, output, `"action": "update_local"`) + actual, err = os.ReadFile(filepath.Join(root, "agents.py")) + require.NoError(t, err) + assert.Equal(t, `from agents import Agent +support = Agent(instructions="Server instructions after link.") +`, string(actual)) +} + +func TestCompletionSourcePullsOneSystemMessage(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Local prompt.\n"), 0o644)) + server := syncdomain.Variation{ + Mode: syncdomain.VariationModeCompletion, Key: "default", Name: "Default", + Messages: []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Server prompt."}}, + } + api := &directAPI{ + variation: pointer(server), configMode: syncdomain.VariationModeCompletion, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionServerCanonical, + }, + } + id := syncdomain.VariationID("production", "support", "default") + + _, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "prompt.md", Format: "markdown", + }, + Yes: true, + }) + + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(root, "prompt.md")) + require.NoError(t, err) + assert.Equal(t, "Server prompt.\n", string(content)) +} + +func TestCompletionMessageListPullsAndPushesLiteralContent(t *testing.T) { + root := initRepository(t) + source := `from agents import Agent +completion = Agent( + name="Completion", + messages=[ + {"role": "system", "content": "Local system."}, + {"role": "user", "content": "Local user."}, + ], + model="gpt-5", +) +` + require.NoError(t, os.WriteFile(filepath.Join(root, "messages.py"), []byte(source), 0o644)) + server := syncdomain.Variation{ + Mode: syncdomain.VariationModeCompletion, Key: "default", Name: "Default", + Messages: []syncdomain.Message{ + {Role: syncdomain.RoleSystem, Content: "Server system."}, + {Role: syncdomain.RoleUser, Content: "Server user."}, + }, + } + api := &directAPI{ + variation: pointer(server), configMode: syncdomain.VariationModeCompletion, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionServerCanonical, + }, + } + id := syncdomain.VariationID("production", "support", "default") + action := syncprompt.SourceLinkAction{ + ID: id, File: "messages.py", Format: "python", Select: `agents[id="completion"]`, + Map: map[string]string{"messages": "messages"}, + } + + _, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, Action: action, Yes: true, + }) + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(root, "messages.py")) + require.NoError(t, err) + assert.Contains(t, string(content), `"content": "Server system."`) + assert.Contains(t, string(content), `"content": "Server user."`) + + updated := strings.Replace(string(content), "Server user.", "Repository user.", 1) + require.NoError(t, os.WriteFile(filepath.Join(root, "messages.py"), []byte(updated), 0o644)) + api.directions["variation:support/default"] = syncdomain.SyncDirectionCodeCanonical + _, _, err = runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SyncAction{ + Filter: syncdomain.Filter{Project: "production", ConfigKey: "support", Keys: []string{"default"}}, + }, + Yes: true, + }) + require.NoError(t, err) + assert.Equal(t, "Repository user.", api.variation.Messages[1].Content) +} + +func TestStandaloneSkillSourceLinkPullsLaunchDarklyContent(t *testing.T) { + root := initRepository(t) + sourcePath := filepath.Join(root, ".agents", "skills", "refunds", "SKILL.md") + require.NoError(t, os.MkdirAll(filepath.Dir(sourcePath), 0o755)) + require.NoError(t, os.WriteFile(sourcePath, []byte(`--- +name: refunds +description: Local refund guidance. +license: Apache-2.0 +--- + +# Refunds + +Use the local policy. +`), 0o644)) + api := &directAPI{ + skills: map[string]versionedSkill{"refunds": { + Skill: syncdomain.Skill{ + Key: "refunds", Name: "Refunds", Description: "Server refund guidance.", + Markdown: "# Refunds\n\nUse the server policy.\n", + }, + Version: 2, + }}, + directions: map[string]syncdomain.SyncDirection{ + "skill:refunds": syncdomain.SyncDirectionServerCanonical, + }, + } + id := syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "production", LookupKey: "refunds"} + + output, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopeSkills, + Action: syncprompt.SourceLinkAction{ + ID: id, File: ".agents/skills/refunds/SKILL.md", Format: "agent-skill", + }, + Yes: true, + }) + + require.NoError(t, err) + assert.Contains(t, output, `"action": "update_local"`) + content, err := os.ReadFile(sourcePath) + require.NoError(t, err) + assert.Contains(t, string(content), "description: Server refund guidance.") + assert.Contains(t, string(content), "Use the server policy.") + assert.Contains(t, string(content), "license: Apache-2.0") + require.FileExists(t, filepath.Join(root, ".launchdarkly", "production", "skills", "refunds.ref.yaml")) +} + +func TestStandaloneToolSourceLinkPushesMappedFields(t *testing.T) { + root := initRepository(t) + source := []byte(`import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +const server = new McpServer({ name: "orders", version: "1" }); +server.registerTool("lookup", { + description: "Look up an order.", + inputSchema: { type: "object" }, +}, handler); +`) + require.NoError(t, os.WriteFile(filepath.Join(root, "tools.ts"), source, 0o644)) + api := &directAPI{ + tools: map[string]versionedTool{"lookup": { + Tool: syncdomain.Tool{Key: "lookup", Description: pointer("Old description."), Schema: map[string]any{"type": "string"}}, + Version: 1, + }}, + directions: map[string]syncdomain.SyncDirection{ + "tool:lookup": syncdomain.SyncDirectionCodeCanonical, + }, + } + id := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "lookup"} + + output, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopeTools, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "tools.ts", Format: "typescript", Select: `tools[id="lookup"]`, + Map: map[string]string{"description": "description", "schema": "inputSchema"}, + }, + Yes: true, + }) + + require.NoError(t, err) + assert.Contains(t, output, `"action": "update_server"`) + assert.Equal(t, "Look up an order.", *api.tools["lookup"].Description) + assert.Equal(t, "object", api.tools["lookup"].Schema["type"]) + actual, err := os.ReadFile(filepath.Join(root, "tools.ts")) + require.NoError(t, err) + assert.Equal(t, source, actual) + require.FileExists(t, filepath.Join(root, ".launchdarkly", "production", "tools", "lookup.ref.yaml")) +} + +func TestSourceLinkWithoutConfirmationWritesNothing(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Server instructions.\n"), 0o644)) + server := variation("Default") + server.Instructions = "Server instructions." + api := &directAPI{ + variation: &server, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionBoth, + }, + } + id := syncdomain.VariationID("production", "support", "default") + + _, notes, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "prompt.md", Format: "markdown", + }, + }) + + require.ErrorContains(t, err, "rerun with --yes") + assert.Contains(t, notes, "Proposed descriptor .launchdarkly/production/configs/support/default.ref.yaml:") + assert.Contains(t, notes, "format: markdown") + require.NoFileExists(t, filepath.Join( + root, ".launchdarkly", "production", "configs", "support", "default.ref.yaml", + )) +} + +func TestStructuredSourceLinksPushAndPull(t *testing.T) { + tests := []struct { + name string + scope syncdomain.Scope + id syncdomain.ResourceID + file string + format string + selectPath string + fieldMap map[string]string + source string + api *directAPI + changeServer func(*directAPI) + wantPush func(*testing.T, *directAPI) + wantPull string + }{ + { + name: "JSON tool", scope: syncdomain.ScopeTools, + id: syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "lookup"}, + file: "tools.json", format: "json", selectPath: `tools[1]`, + fieldMap: map[string]string{"description": "description", "schema": "inputSchema"}, + source: `{"tools":[{"name":"keep","description":"Keep","inputSchema":{"type":"null"}},{"name":"lookup","description":"Local lookup","inputSchema":{"type":"object"}}]}` + "\n", + api: &directAPI{ + tools: map[string]versionedTool{"lookup": { + Tool: syncdomain.Tool{Key: "lookup", Description: pointer("Server lookup"), Schema: map[string]any{"type": "string"}}, + Version: 1, + }}, + directions: map[string]syncdomain.SyncDirection{"tool:lookup": syncdomain.SyncDirectionCodeCanonical}, + }, + changeServer: func(api *directAPI) { + tool := api.tools["lookup"] + tool.Description = pointer("Pulled lookup") + api.tools["lookup"] = tool + api.directions["tool:lookup"] = syncdomain.SyncDirectionServerCanonical + }, + wantPush: func(t *testing.T, api *directAPI) { + assert.Equal(t, "Local lookup", *api.tools["lookup"].Description) + assert.Equal(t, "object", api.tools["lookup"].Schema["type"]) + }, + wantPull: `"description":"Pulled lookup"`, + }, + { + name: "YAML variation", scope: syncdomain.ScopePrompts, + id: syncdomain.VariationID("production", "support", "default"), + file: "agents.yaml", format: "yaml", selectPath: `agents[1]`, + fieldMap: map[string]string{"prompt": "prompt", "model": "model"}, + source: "agents:\n - {name: keep, prompt: Keep, model: old}\n - name: support\n prompt: Local YAML prompt\n model: gpt-5\n", + api: &directAPI{ + variation: pointer(variation("Server")), + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionCodeCanonical, + }, + }, + changeServer: func(api *directAPI) { + api.variation.Instructions = "Pulled YAML prompt" + api.directions["variation:support/default"] = syncdomain.SyncDirectionServerCanonical + }, + wantPush: func(t *testing.T, api *directAPI) { + assert.Equal(t, "Local YAML prompt", api.variation.Instructions) + assert.Equal(t, "gpt-5", api.variation.Model["modelName"]) + }, + wantPull: "prompt: Pulled YAML prompt", + }, + { + name: "TOML variation", scope: syncdomain.ScopePrompts, + id: syncdomain.VariationID("production", "support", "default"), + file: "agents.toml", format: "toml", selectPath: "agents[1]", + fieldMap: map[string]string{"prompt": "prompt", "model": "model"}, + source: "agents = [{ name = \"keep\", prompt = \"Keep\", model = \"old\" }, { name = \"support\", prompt = \"Local TOML prompt\", model = \"gpt-5\" }]\n", + api: &directAPI{ + variation: pointer(variation("Server")), + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionCodeCanonical, + }, + }, + changeServer: func(api *directAPI) { + api.variation.Instructions = "Pulled TOML prompt" + api.directions["variation:support/default"] = syncdomain.SyncDirectionServerCanonical + }, + wantPush: func(t *testing.T, api *directAPI) { + assert.Equal(t, "Local TOML prompt", api.variation.Instructions) + assert.Equal(t, "gpt-5", api.variation.Model["modelName"]) + }, + wantPull: `prompt = "Pulled TOML prompt"`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, test.file), []byte(test.source), 0o644)) + _, _, err := runScoped(t, root, test.api, syncprompt.Options{ + Scope: test.scope, + Action: syncprompt.SourceLinkAction{ + ID: test.id, File: test.file, Format: test.format, + Select: test.selectPath, Map: test.fieldMap, + }, + Yes: true, + }) + require.NoError(t, err) + test.wantPush(t, test.api) + lock, err := os.ReadFile(filepath.Join(root, ".launchdarkly", "sync.lock")) + require.NoError(t, err) + assert.Contains(t, string(lock), "formatVersion: 1") + assert.Contains(t, string(lock), "indexedSelections:") + + test.changeServer(test.api) + filter := syncdomain.Filter{Project: "production", Keys: []string{test.id.LookupKey}} + if test.id.Kind == syncdomain.KindVariation { + filter.ConfigKey, filter.Keys[0], _ = test.id.VariationKeys() + } + _, _, err = runScoped(t, root, test.api, syncprompt.Options{ + Scope: test.scope, Action: syncprompt.SyncAction{Filter: filter}, Yes: true, + }) + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(root, test.file)) + require.NoError(t, err) + assert.Contains(t, string(content), test.wantPull) + assert.Contains(t, string(content), "Keep") + + _, _, err = runScoped(t, root, test.api, syncprompt.Options{ + Scope: test.scope, Action: syncprompt.SyncAction{Filter: filter}, Yes: true, + }) + require.NoError(t, err) + }) + } +} + +func TestIndexedSourceReorderBlocksSyncWithoutWrites(t *testing.T) { + root := initRepository(t) + sourcePath := filepath.Join(root, "tools.json") + original := `{"tools":[{"name":"keep","description":"Keep","inputSchema":{"type":"null"}},{"name":"lookup","description":"Local lookup","inputSchema":{"type":"object"}}]}` + "\n" + require.NoError(t, os.WriteFile(sourcePath, []byte(original), 0o644)) + api := &directAPI{ + tools: map[string]versionedTool{"lookup": { + Tool: syncdomain.Tool{ + Key: "lookup", Description: pointer("Server lookup"), Schema: map[string]any{"type": "string"}, + }, + Version: 1, + }}, + directions: map[string]syncdomain.SyncDirection{ + "tool:lookup": syncdomain.SyncDirectionCodeCanonical, + }, + } + id := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "lookup"} + _, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopeTools, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "tools.json", Format: "json", Select: "tools[1]", + Map: map[string]string{"description": "description", "schema": "inputSchema"}, + }, + Yes: true, + }) + require.NoError(t, err) + assert.Equal(t, "Local lookup", *api.tools["lookup"].Description) + + reordered := `{"tools":[{"name":"lookup","description":"Different item","inputSchema":{"type":"boolean"}},{"name":"keep","description":"Keep","inputSchema":{"type":"null"}}]}` + "\n" + require.NoError(t, os.WriteFile(sourcePath, []byte(reordered), 0o644)) + _, _, err = runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopeTools, + Action: syncprompt.SyncAction{Filter: syncdomain.Filter{ + Project: "production", Keys: []string{"lookup"}, + }}, + Yes: true, + }) + + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorIndexedSelectionChanged, problem.Code) + assert.Equal(t, "Local lookup", *api.tools["lookup"].Description) + content, readErr := os.ReadFile(sourcePath) + require.NoError(t, readErr) + assert.Equal(t, reordered, string(content)) +} + +func TestReadOnlyMappedFieldBlocksCompleteLink(t *testing.T) { + root := initRepository(t) + source := `import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +const server = new McpServer({ name: "orders", version: "1" }); +server.registerTool("lookup", { + description: "Local description", + inputSchema: makeSchema(), +}, handler); +` + require.NoError(t, os.WriteFile(filepath.Join(root, "tools.ts"), []byte(source), 0o644)) + api := &directAPI{ + tools: map[string]versionedTool{"lookup": { + Tool: syncdomain.Tool{ + Key: "lookup", Description: pointer("Server description"), + Schema: map[string]any{"type": "object"}, + }, + Version: 1, + }}, + directions: map[string]syncdomain.SyncDirection{ + "tool:lookup": syncdomain.SyncDirectionCodeCanonical, + }, + } + id := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "lookup"} + action := syncprompt.SourceLinkAction{ + ID: id, File: "tools.ts", Format: "typescript", Select: `tools[id="lookup"]`, + Map: map[string]string{"description": "description", "schema": "inputSchema"}, + } + + _, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopeTools, Action: action, Yes: true, + }) + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorFieldReadOnly, problem.Code) + assert.Contains(t, problem.Message, `field "schema"`) + assert.Equal(t, "Server description", *api.tools["lookup"].Description) + assert.Equal(t, "object", api.tools["lookup"].Schema["type"]) + content, err := os.ReadFile(filepath.Join(root, "tools.ts")) + require.NoError(t, err) + assert.Contains(t, string(content), `description: "Local description"`) + assert.Contains(t, string(content), "inputSchema: makeSchema()") + require.NoFileExists(t, filepath.Join(root, ".launchdarkly", "production", "tools", "lookup.ref.yaml")) +} + +func TestCLILinksAndDetachesRepositorySource(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Local CLI prompt.\n"), 0o644)) + server := variation("Server") + api := &directAPI{ + variation: &server, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionBoth, + }, + } + + _, _, err := runPrompt( + t, root, api, + "link", + "--file", "prompt.md", + "--format", "markdown", + "--project", "production", + "--config-key", "support", + "--key", "default", + "--conflict", "local", + "--yes", "--no-input", + ) + require.NoError(t, err) + assert.Equal(t, "Local CLI prompt.", api.variation.Instructions) + descriptor := filepath.Join(root, ".launchdarkly", "production", "configs", "support", "default.ref.yaml") + require.FileExists(t, descriptor) + + _, _, err = runPrompt( + t, root, api, + "detach", + "--project", "production", + "--config-key", "support", + "--key", "default", + "--yes", "--no-input", + ) + require.NoError(t, err) + require.NoFileExists(t, descriptor) + require.FileExists(t, filepath.Join(root, "prompt.md")) +} + +func TestMarkdownProviderProfilesPullAndPreserveMetadata(t *testing.T) { + tests := []struct { + name, format, source, metadata string + }{ + {name: "Cursor", format: "cursor", source: "---\nglobs: \"src/**/*.ts\"\nalwaysApply: false\n---\n\nLocal\n", metadata: "alwaysApply: false"}, + {name: "Copilot", format: "copilot", source: "---\napplyTo: \"src/**/*.ts\"\n---\n\nLocal\n", metadata: "applyTo: \"src/**/*.ts\""}, + {name: "Claude", format: "claude-markdown", source: "---\nname: support\nmodel: sonnet\n---\n\nLocal\n", metadata: "model: sonnet"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "instructions.md"), []byte(test.source), 0o644)) + server := variation("Server") + server.Instructions = "Pulled profile instructions." + api := &directAPI{ + variation: &server, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionServerCanonical, + }, + } + id := syncdomain.VariationID("production", "support", "default") + + _, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "instructions.md", Format: test.format, + }, + Yes: true, + }) + + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(root, "instructions.md")) + require.NoError(t, err) + assert.Contains(t, string(content), test.metadata) + assert.Contains(t, string(content), "Pulled profile instructions.") + }) + } +} + // runScoped runs options through Runner.Run without a terminal, and returns // the JSON output and the notes. A question fails the run. func runScoped(t *testing.T, root string, api *directAPI, options syncprompt.Options) (string, string, error) { diff --git a/internal/sync/prompt/action.go b/internal/sync/prompt/action.go index 6278e892..ffba2c8a 100644 --- a/internal/sync/prompt/action.go +++ b/internal/sync/prompt/action.go @@ -8,6 +8,7 @@ import ( syncdomain "github.com/launchdarkly/ldcli/internal/sync" synccreate "github.com/launchdarkly/ldcli/internal/sync/create" + syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" ) // CommandAction is one sync operation. scope is the scope of the command. @@ -54,6 +55,15 @@ type DetachAction struct { Archive bool } +// SourceLinkAction creates a .ref.yaml descriptor and then syncs its resource. +type SourceLinkAction struct { + ID syncdomain.ResourceID + File string + Format string + Select string + Map map[string]string +} + func (action SyncAction) validate(scope syncdomain.Scope) error { if action.Watch && action.DryRun { return errors.New("watch does not support --dry-run") @@ -93,6 +103,30 @@ func (action AttachAction) validate(syncdomain.Scope) error { return validateProjectFlag(action.ProjectKey, action.Target) } +func (action SourceLinkAction) validate(scope syncdomain.Scope) error { + switch { + case !scope.Includes(action.ID.Kind): + return fmt.Errorf("%s cannot link %s", scope, action.ID.Label()) + case action.File == "": + return errors.New("--file is required") + case action.Format == "": + return errors.New("--format is required") + case syncdomain.ValidateKey(action.ID.ProjectKey) != nil: + return fmt.Errorf("invalid project key %q", action.ID.ProjectKey) + case syncdomain.ValidateKey(action.ID.LookupKey) != nil && action.ID.Kind != syncdomain.KindVariation: + return fmt.Errorf("invalid resource key %q", action.ID.LookupKey) + } + if action.ID.Kind == syncdomain.KindVariation { + if _, _, err := action.ID.VariationKeys(); err != nil { + return err + } + } + _, err := syncreference.NormalizeReference(action.ID, syncdomain.Reference{ + File: "source", Format: action.Format, Select: action.Select, Map: action.Map, + }) + return err +} + // validateSelections rejects positional selectors outside the prompts scope, // and resources that come both from positional selectors and from target // flags. kind is the kind that --key names. @@ -198,6 +232,19 @@ func (options Options) watching() bool { // syncAction returns the sync that the command runs. After an attach or a // link, it is a sync of every resource. func (options Options) syncAction() SyncAction { - action, _ := options.Action.(SyncAction) - return action + switch action := options.Action.(type) { + case SyncAction: + return action + case SourceLinkAction: + configKey := "" + keys := []string{action.ID.LookupKey} + if action.ID.Kind == syncdomain.KindVariation { + configKey, keys[0], _ = action.ID.VariationKeys() + } + return SyncAction{Filter: syncdomain.Filter{ + Project: action.ID.ProjectKey, ConfigKey: configKey, Keys: keys, + }} + default: + return SyncAction{} + } } diff --git a/internal/sync/prompt/output.go b/internal/sync/prompt/output.go index 40a45c1d..734b9173 100644 --- a/internal/sync/prompt/output.go +++ b/internal/sync/prompt/output.go @@ -57,6 +57,26 @@ func newResourceOutput(id ResourceID) resourceOutput { return resourceOutput{ResourceKind: string(id.Kind), ProjectKey: id.ProjectKey, LookupKey: id.LookupKey} } +type sourceOutput struct { + File string `json:"file"` + Format string `json:"format"` + Select string `json:"select"` + Map map[string]string `json:"map"` + Direction syncdomain.SyncDirection `json:"direction"` + Dependencies []string `json:"dependencies,omitempty"` +} + +func plannedSource(resource PlannedResource) *sourceOutput { + if resource.Source == nil { + return nil + } + return &sourceOutput{ + File: resource.Source.File, Format: resource.Source.Format, + Select: resource.Source.Select, Map: resource.Source.Map, + Direction: resource.Direction, Dependencies: resource.SourceDependencies, + } +} + // writePlanOutput writes the plan in the selected output kind. Plain text and // Markdown show a full diff for a conflict, and for every row when showDiffs // is true. @@ -71,22 +91,37 @@ func writePlanOutput(out io.Writer, outputKind string, plan Plan, showDiffs bool type planResourceOutput struct { resourceOutput - Action Action `json:"action"` - SyncedElsewhere bool `json:"syncedElsewhere,omitempty"` - Suggestion string `json:"suggestion,omitempty"` - Error string `json:"error,omitempty"` - Diff variationDiffFields `json:"diff,omitempty"` + Action Action `json:"action"` + SyncedElsewhere bool `json:"syncedElsewhere,omitempty"` + Suggestion string `json:"suggestion,omitempty"` + Error string `json:"error,omitempty"` + ErrorCode syncdomain.ErrorCode `json:"errorCode,omitempty"` + ErrorFile string `json:"errorFile,omitempty"` + ErrorLine int `json:"errorLine,omitempty"` + ErrorColumn int `json:"errorColumn,omitempty"` + NextStep string `json:"nextStep,omitempty"` + Diff variationDiffFields `json:"diff,omitempty"` + Source *sourceOutput `json:"source,omitempty"` } resources := make([]planResourceOutput, 0, len(plan.Resources)) for _, resource := range plan.Resources { - resources = append(resources, planResourceOutput{ + item := planResourceOutput{ resourceOutput: newResourceOutput(resource.ID), Action: resource.Action, SyncedElsewhere: resource.SyncedElsewhere, Suggestion: staleSuggestion(resource), Error: resource.Error, Diff: resource.Diff, - }) + Source: plannedSource(resource), + } + if resource.Problem != nil { + item.ErrorCode = resource.Problem.Code + item.ErrorFile = resource.Problem.File + item.ErrorLine = resource.Problem.Line + item.ErrorColumn = resource.Problem.Column + item.NextStep = resource.Problem.Suggestion + } + resources = append(resources, item) } return writeJSON(out, map[string]any{"resources": resources}) } @@ -108,11 +143,13 @@ func writeOutcomeOutput(out io.Writer, outputKind string, plan Plan, outcomes [] resourceOutput ResourceOutcome Create *skillCreateOutput `json:"create,omitempty"` + Source *sourceOutput `json:"source,omitempty"` } resources := make([]outcomeOutput, 0, len(outcomes)) for _, outcome := range outcomes { resources = append(resources, outcomeOutput{ - resourceOutput: newResourceOutput(outcome.ID), ResourceOutcome: outcome, Create: skillCreate(planned[outcome.ID]), + resourceOutput: newResourceOutput(outcome.ID), ResourceOutcome: outcome, + Create: skillCreate(planned[outcome.ID]), Source: plannedSource(planned[outcome.ID]), }) } return writeJSON(out, map[string]any{"resources": resources}) @@ -441,6 +478,9 @@ func changedFieldsReason(resource PlannedResource, verb string) string { if part, ok := resource.Diff["variation"]; ok { fields = append(fields, changedKeys(part.Before, part.After)...) } + if part, ok := resource.Diff["projection"]; ok { + fields = append(fields, changedKeys(part.Before, part.After)...) + } for _, name := range []string{"tools", "skills"} { part, ok := resource.Diff[name] switch { diff --git a/internal/sync/prompt/runner.go b/internal/sync/prompt/runner.go index 88f7d986..a6739a01 100644 --- a/internal/sync/prompt/runner.go +++ b/internal/sync/prompt/runner.go @@ -17,6 +17,7 @@ import ( syncbootstrap "github.com/launchdarkly/ldcli/internal/sync/bootstrap" syncdetach "github.com/launchdarkly/ldcli/internal/sync/detach" syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive" + synclink "github.com/launchdarkly/ldcli/internal/sync/link" synclocal "github.com/launchdarkly/ldcli/internal/sync/local" syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" syncsource "github.com/launchdarkly/ldcli/internal/sync/source" @@ -77,6 +78,8 @@ func (runner Runner) Run(options Options) error { return runner.runAdd(options, workspace, action) case DetachAction: return runner.runDetach(options, workspace, action) + case SourceLinkAction: + return runner.runSourceLink(options, workspace, action) case AttachAction: attached, err := runner.runAttach(options, workspace, action) if err != nil || !attached { @@ -90,6 +93,23 @@ func (runner Runner) Run(options Options) error { return runner.runWorkspaceSync(options, workspace, nil) } +func (runner Runner) runSourceLink(options Options, workspace syncWorkspace, action SourceLinkAction) error { + prepared, err := synclink.Prepare(synclink.Options{ + Store: workspace.local, + RepositoryRoot: workspace.root, + WorkingDirectory: options.WorkingDirectory, + ID: action.ID, + File: action.File, + Format: action.Format, + Select: action.Select, + Map: action.Map, + }) + if err != nil { + return err + } + return runner.runWorkspaceSyncWithSource(options, workspace, nil, &prepared) +} + // nothingToSync tells the user how to start a sync of every kind. const nothingToSync = "Nothing to sync in this repository. " + "To start, run ldcli sync prompts add, ldcli sync tools add, or ldcli sync skills add." diff --git a/internal/sync/prompt/runner_test.go b/internal/sync/prompt/runner_test.go index 4d7d1472..9f2cac76 100644 --- a/internal/sync/prompt/runner_test.go +++ b/internal/sync/prompt/runner_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "testing" "time" @@ -186,6 +187,12 @@ func TestRunnerChecksAttachmentTerminalOnOutput(t *testing.T) { } func TestValidateOptions(t *testing.T) { + require.ErrorContains(t, validateOptions(Options{ + Scope: syncdomain.ScopePrompts, + Action: SourceLinkAction{ + ID: syncdomain.VariationID("project", "config", "default"), File: "prompt.md", + }, + }), "--format is required") require.ErrorContains(t, validateOptions(Options{ Action: SyncAction{Watch: true, DryRun: true}, }), "watch does not support --dry-run") @@ -211,6 +218,21 @@ func TestSamePlannedResourceStateDetectsChangedAttachmentPins(t *testing.T) { assert.False(t, samePlannedResourceState(reviewed, current)) } +func TestSamePlannedResourceStateDetectsChangedIndexedSelection(t *testing.T) { + reviewed := PlannedResource{ + ID: ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/default"}, + Action: ActionUpdateServer, + IndexedSelections: []syncdomain.IndexedSelection{{ + Path: "select#0", CollectionLength: 2, Fingerprint: "sha256:" + strings.Repeat("a", 64), + }}, + } + current := reviewed + current.IndexedSelections = slices.Clone(reviewed.IndexedSelections) + current.IndexedSelections[0].Fingerprint = "sha256:" + strings.Repeat("b", 64) + + assert.False(t, samePlannedResourceState(reviewed, current)) +} + func TestAttachToVariationPreservesExistingSharedAttachmentEdits(t *testing.T) { root := t.TempDir() store := synclocal.NewStore(root) diff --git a/internal/sync/prompt/state.go b/internal/sync/prompt/state.go index f77a75a6..19671348 100644 --- a/internal/sync/prompt/state.go +++ b/internal/sync/prompt/state.go @@ -171,6 +171,22 @@ func checkSourcePublications(repositoryRoot string, resources []PlannedResource) } } +func allowReviewedSourceChange(repositoryRoot string, plan *Plan, id ResourceID) { + for index := range plan.Resources { + resource := &plan.Resources[index] + if resource.ID != id || resource.Source == nil || resource.Problem == nil || + resource.Problem.Code != syncdomain.ErrorBindingStale && + resource.Problem.Code != syncdomain.ErrorIndexedSelectionChanged { + continue + } + resource.Error, resource.Problem = "", nil + resource.Action = chooseAction(resource.BaselineFingerprint != "", *resource) + enforceDirection(resource, resource.Direction) + checkSourcePublications(repositoryRoot, plan.Resources) + return + } +} + func applySourceDirections(client syncapi.Client, resources []PlannedResource) error { byProject := make(map[string][]ResourceID) for _, resource := range resources { diff --git a/internal/sync/prompt/sync.go b/internal/sync/prompt/sync.go index 93fb8fea..839effa8 100644 --- a/internal/sync/prompt/sync.go +++ b/internal/sync/prompt/sync.go @@ -1,11 +1,15 @@ package prompt import ( + "bytes" "errors" + "io" "slices" syncdomain "github.com/launchdarkly/ldcli/internal/sync" syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + syncconsole "github.com/launchdarkly/ldcli/internal/sync/console" + synclink "github.com/launchdarkly/ldcli/internal/sync/link" ) // runWorkspaceSync runs one sync in five steps: @@ -19,6 +23,15 @@ import ( // 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 { + return runner.runWorkspaceSyncWithSource(options, workspace, watcher, nil) +} + +func (runner Runner) runWorkspaceSyncWithSource( + options Options, + workspace syncWorkspace, + watcher *sourceWatcher, + prepared *synclink.Prepared, +) error { options = options.withDefaults() client := runner.api(options) @@ -32,10 +45,25 @@ func (runner Runner) runWorkspaceSync(options Options, workspace syncWorkspace, } target := options.syncAction() - reviewed, err := workspace.loadState(client, options.Scope, target.Filter) + var reviewed workspaceState + var err error + if prepared == nil { + reviewed, err = workspace.loadState(client, options.Scope, target.Filter) + } else { + reviewed, err = workspace.loadStateWithSource( + client, options.Scope, target.Filter, &prepared.Definition, + ) + } if err != nil { return err } + if link, ok := options.Action.(SourceLinkAction); ok { + allowReviewedSourceChange(workspace.root, &reviewed.plan, link.ID) + } + if prepared != nil { + reviewed.plan.ForceConfirmation = true + writeDescriptorPreview(options.ErrorOutput, *prepared) + } if options.dryRun() { writePlanNotes(options.ErrorOutput, reviewed.plan) if err := writePlanOutput(options.Output, options.OutputKind, reviewed.plan, options.Diff); err != nil { @@ -63,12 +91,21 @@ func (runner Runner) runWorkspaceSync(options Options, workspace syncWorkspace, if !proceed { return nil } + if prepared != nil { + if _, err := prepared.Write(workspace.local); err != nil { + return err + } + _ = syncconsole.New(options.Output).Printf("Linked %s.\n", prepared.Path) + } // Read both sides again, so that no action uses state from before the review. current, err := workspace.loadState(client, options.Scope, target.Filter) if err != nil { return err } + if link, ok := options.Action.(SourceLinkAction); ok { + allowReviewedSourceChange(workspace.root, ¤t.plan, link.ID) + } if err := checkUnchanged(options, reviewed, current); err != nil { return err } @@ -76,6 +113,24 @@ func (runner Runner) runWorkspaceSync(options Options, workspace syncWorkspace, return err } +func writeDescriptorPreview(output io.Writer, prepared synclink.Prepared) { + console := syncconsole.New(output) + if len(prepared.Previous) != 0 && !bytes.Equal(prepared.Previous, prepared.Content) { + _ = console.Printf("Current descriptor %s:\n%s\n", prepared.Path, prepared.Previous) + } + _ = console.Printf("Proposed descriptor %s:\n%s\n", prepared.Path, prepared.Content) + var dependencies []string + if prepared.Definition.Variation != nil { + dependencies = prepared.Definition.Variation.Dependencies + } + if prepared.Definition.Attachment != nil { + dependencies = prepared.Definition.Attachment.Dependencies + } + for _, dependency := range dependencies { + _ = console.Printf("Mapped dependency: %s\n", dependency) + } +} + // 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) { diff --git a/internal/sync/prompt/watch_test.go b/internal/sync/prompt/watch_test.go index dcda1b84..ebb1990e 100644 --- a/internal/sync/prompt/watch_test.go +++ b/internal/sync/prompt/watch_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" syncdomain "github.com/launchdarkly/ldcli/internal/sync" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" ) func TestWatchWorkspaceWaitsForDebouncedChange(t *testing.T) { @@ -160,6 +161,73 @@ func TestWatchWorkspaceRefreshesPlanAfterSourceChangesDuringSync(t *testing.T) { require.NoError(t, <-done) } +func TestWatchWorkspaceRefreshesReferencedFilesBeforeRebuildingPlan(t *testing.T) { + root := t.TempDir() + store := synclocal.NewStore(root) + _, err := store.Add([]synclocal.VariationFile{{ + ProjectKey: "project", + ConfigKey: "config", + Variation: syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "prompt", Name: "Prompt", Instructions: "Initial", + }, + }}) + require.NoError(t, err) + wrapper := filepath.Join(root, syncdomain.RootDir, "project", "configs", "config", "prompt.prompt.md") + referencePath := filepath.Join(root, "prompts", "prompt.md") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var attempts atomic.Int32 + attempted := make(chan struct{}, 3) + done := make(chan error, 1) + go func() { + done <- watchWorkspace(ctx, root, 15*time.Millisecond, func(*sourceWatcher) error { + attempt := attempts.Add(1) + if attempt == 1 { + if err := os.MkdirAll(filepath.Dir(referencePath), 0o755); err != nil { + return err + } + if err := os.WriteFile(referencePath, []byte("Referenced"), 0o644); err != nil { + return err + } + if err := os.Remove(wrapper); err != nil { + return err + } + if _, err := store.WriteDescriptor( + syncdomain.VariationID("project", "config", "prompt"), + synclocal.Reference{File: "prompts/prompt.md", Format: "markdown"}, + ); err != nil { + return err + } + attempted <- struct{}{} + return errRefreshWatchPlan + } + attempted <- struct{}{} + return nil + }, io.Discard) + }() + + time.Sleep(30 * time.Millisecond) + require.NoError(t, os.WriteFile(wrapper, []byte("trigger"), 0o644)) + for attempt := 0; attempt < 2; attempt++ { + select { + case <-attempted: + case <-time.After(time.Second): + t.Fatal("watch did not rebuild the plan") + } + } + + require.NoError(t, os.WriteFile(referencePath, []byte("Changed"), 0o644)) + select { + case <-attempted: + case <-time.After(time.Second): + t.Fatal("watch did not track the newly referenced file") + } + + cancel() + require.NoError(t, <-done) +} + func TestWatchWorkspaceRetriesEditsMadeDuringSuccessfulSync(t *testing.T) { root := t.TempDir() wrapper := filepath.Join(root, syncdomain.RootDir, "project", "configs", "config", "prompt.prompt.md") @@ -257,6 +325,38 @@ func TestWatchedConflictPromptCancellationStopsWatch(t *testing.T) { require.ErrorIs(t, err, context.Canceled) } +func TestWatchWorkspaceTracksMissingReferencedFileParent(t *testing.T) { + root := t.TempDir() + _, err := synclocal.NewStore(root).WriteDescriptor( + syncdomain.VariationID("project", "config", "prompt"), + synclocal.Reference{File: "prompts/nested/prompt.md", Format: "markdown"}, + ) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + synced := make(chan struct{}, 1) + done := make(chan error, 1) + go func() { + done <- watchWorkspace(ctx, root, 15*time.Millisecond, func(*sourceWatcher) error { + synced <- struct{}{} + return nil + }, io.Discard) + }() + + time.Sleep(30 * time.Millisecond) + referencePath := filepath.Join(root, "prompts", "nested", "prompt.md") + require.NoError(t, os.MkdirAll(filepath.Dir(referencePath), 0o755)) + require.NoError(t, os.WriteFile(referencePath, []byte("created"), 0o644)) + select { + case <-synced: + case <-time.After(time.Second): + t.Fatal("watch did not detect a referenced file created under a missing parent") + } + + cancel() + require.NoError(t, <-done) +} + func TestWatchWorkspaceRewatchesRecreatedManagedTree(t *testing.T) { root := t.TempDir() wrapper := filepath.Join(root, syncdomain.RootDir, "project", "configs", "config", "prompt.prompt.md") @@ -293,6 +393,40 @@ func TestWatchWorkspaceRewatchesRecreatedManagedTree(t *testing.T) { require.NoError(t, <-done) } +func TestWatchWorkspaceDetectsReferencedFileChange(t *testing.T) { + root := t.TempDir() + referencePath := filepath.Join(root, "prompt.md") + require.NoError(t, os.WriteFile(referencePath, []byte("initial"), 0o644)) + _, err := synclocal.NewStore(root).WriteDescriptor( + syncdomain.VariationID("project", "config", "prompt"), + synclocal.Reference{File: "prompt.md", Format: "markdown"}, + ) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + synced := make(chan struct{}, 1) + done := make(chan error, 1) + var output bytes.Buffer + go func() { + done <- watchWorkspace(ctx, root, 15*time.Millisecond, func(*sourceWatcher) error { + synced <- struct{}{} + return nil + }, &output) + }() + + time.Sleep(30 * time.Millisecond) + require.NoError(t, os.WriteFile(referencePath, []byte("changed"), 0o644)) + select { + case <-synced: + case <-time.After(time.Second): + t.Fatal("watch did not detect referenced file change") + } + cancel() + require.NoError(t, <-done) + require.Contains(t, output.String(), "- .launchdarkly/project/configs/config/prompt.ref.yaml\n") + require.Contains(t, output.String(), "- prompt.md\n") +} + func TestSourceWatcherRecognizesNewManagedResourceKinds(t *testing.T) { root := t.TempDir() managedRoot := filepath.Join(root, syncdomain.RootDir)