From 9c4b6b28933c7be203c316c29eb2408526690c66 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Thu, 24 Sep 2026 13:54:09 -0400 Subject: [PATCH] feat(sync): link and detach prompt resources --- internal/sync/detach/detach.go | 173 +++++++++++++ internal/sync/detach/detach_test.go | 175 +++++++++++++ internal/sync/link/link.go | 373 ++++++++++++++++++++++++++++ internal/sync/link/link_test.go | 160 ++++++++++++ internal/sync/local/replace.go | 23 ++ 5 files changed, 904 insertions(+) create mode 100644 internal/sync/detach/detach.go create mode 100644 internal/sync/detach/detach_test.go create mode 100644 internal/sync/link/link.go create mode 100644 internal/sync/link/link_test.go diff --git a/internal/sync/detach/detach.go b/internal/sync/detach/detach.go new file mode 100644 index 00000000..90a1513e --- /dev/null +++ b/internal/sync/detach/detach.go @@ -0,0 +1,173 @@ +package detach + +import ( + "errors" + "fmt" + "io" + "path" + "slices" + "strings" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncconsole "github.com/launchdarkly/ldcli/internal/sync/console" + syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" + syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" +) + +// Resource identifies one local or manifested resource that can be detached. +type Resource = syncdomain.ResourceID + +// Options contains the local stores and streams used by detach. +type Options struct { + RepositoryRoot string + Store synclocal.Store + Manifest syncmanifest.Store + Input io.Reader + Output io.Writer +} + +// Run lets the user select resources and removes their local sync state. +func Run(options Options) error { + resources, manifest, manifestExists, err := loadResources(options.RepositoryRoot, options.Manifest) + 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") + } + + choices := make([]syncinteractive.Choice[Resource], 0, len(resources)) + for _, resource := range resources { + choices = append(choices, syncinteractive.Choice[Resource]{ + Title: resource.ProjectKey + "/" + resource.LookupKey, + Description: string(resource.Kind), + Value: resource, + }) + } + selected, canceled, err := syncinteractive.MultiSelect( + options.Input, + options.Output, + "Select resources to detach", + "Detached resources remain in LaunchDarkly.", + choices, + ) + if err != nil { + return err + } + if canceled { + return nil + } + if err := detachResources(options, manifest, manifestExists, 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) + } + return nil +} + +// loadResources returns the union of local wrappers and manifested resources. +func loadResources(repositoryRoot string, manifestStore syncmanifest.Store) ([]Resource, syncmanifest.Manifest, bool, error) { + manifest, manifestExists, err := manifestStore.Load() + if err != nil { + return nil, syncmanifest.Manifest{}, false, err + } + + resources := make(map[Resource]struct{}, len(manifest.Resources)) + for _, resource := range manifest.Resources { + resources[resource.ID()] = struct{}{} + } + + files, err := synclocal.SourceFiles(repositoryRoot) + if err != nil { + return nil, syncmanifest.Manifest{}, false, err + } + for _, file := range files { + resource, ok := resourceFromWrapperPath(file) + if ok { + resources[resource] = struct{}{} + } + } + + result := make([]Resource, 0, len(resources)) + for resource := range resources { + result = append(result, resource) + } + slices.SortFunc(result, syncdomain.CompareResourceIDs) + return result, manifest, manifestExists, 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 +} + +// detachResources removes selected resources from the manifest before deleting local wrappers. +func detachResources(options Options, original syncmanifest.Manifest, manifestExists bool, selected []Resource) error { + selectedSet := make(map[Resource]struct{}, len(selected)) + for _, resource := range selected { + selectedSet[resource] = struct{}{} + } + + updated := syncmanifest.New() + for _, resource := range original.Resources { + if _, detach := selectedSet[resource.ID()]; !detach { + updated.Resources = append(updated.Resources, resource) + } + } + if err := options.Manifest.Write(updated); err != nil { + return err + } + + 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, "/") { + continue + } + exists, err := options.Store.VariationExists(resource.ProjectKey, configKey, variationKey) + if err != nil { + return errors.Join(err, restoreManifest(options.Manifest, original, manifestExists)) + } + if exists { + deletions = append(deletions, synclocal.VariationDeletion{ + ProjectKey: resource.ProjectKey, ConfigKey: configKey, VariationKey: variationKey, + }) + } + } + + if _, err := options.Store.DeleteVariations(deletions); err != nil { + return errors.Join(err, restoreManifest(options.Manifest, original, manifestExists)) + } + if err := options.Store.RemoveEmptyDirectories(); err != nil { + return err + } + return nil +} + +// restoreManifest restores the manifest when local wrapper deletion fails. +func restoreManifest(store syncmanifest.Store, manifest syncmanifest.Manifest, existed bool) error { + if existed { + return store.Write(manifest) + } + return store.Remove() +} diff --git a/internal/sync/detach/detach_test.go b/internal/sync/detach/detach_test.go new file mode 100644 index 00000000..eede1e82 --- /dev/null +++ b/internal/sync/detach/detach_test.go @@ -0,0 +1,175 @@ +package detach + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" + syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" + syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" +) + +func TestLoadResourcesUnionsLocalAndManifestResources(t *testing.T) { + root := t.TempDir() + store := synclocal.NewStore(root) + _, err := store.Add([]synclocal.VariationFile{{ + ProjectKey: "project", ConfigKey: "config", Variation: testVariation("local"), + }}) + require.NoError(t, err) + + manifestStore := syncmanifest.NewStore(root) + require.NoError(t, manifestStore.Write(syncmanifest.Manifest{ + FormatVersion: syncmanifest.FormatVersion, + Resources: []syncmanifest.Resource{{ + ResourceKind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/manifest-only", + Fingerprint: testFingerprint(), + }}, + })) + + resources, _, exists, err := loadResources(root, manifestStore) + + require.NoError(t, err) + require.True(t, exists) + assert.Equal(t, []Resource{ + {Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/local"}, + {Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/manifest-only"}, + }, resources) +} + +func TestDetachResourcesRemovesWrapperAndManifestButKeepsReferencedFile(t *testing.T) { + root := t.TempDir() + referencePath := filepath.Join(root, "prompts", "prompt.md") + require.NoError(t, os.MkdirAll(filepath.Dir(referencePath), 0o755)) + require.NoError(t, os.WriteFile(referencePath, []byte("Keep me.\n"), 0o644)) + + store := synclocal.NewStore(root) + variation := testVariation("prompt") + _, err := store.Add([]synclocal.VariationFile{{ + ProjectKey: "project", ConfigKey: "config", Variation: variation, + Ref: &synclocal.Reference{File: "prompts/prompt.md", Format: syncreference.PlainMarkdown}, + }}) + require.NoError(t, err) + + manifestStore := syncmanifest.NewStore(root) + original := syncmanifest.Manifest{ + FormatVersion: syncmanifest.FormatVersion, + Resources: []syncmanifest.Resource{{ + ResourceKind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/prompt", + Fingerprint: testFingerprint(), + }}, + } + require.NoError(t, manifestStore.Write(original)) + + resource := Resource{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/prompt"} + err = detachResources(Options{Store: store, Manifest: manifestStore}, original, true, []Resource{resource}) + + require.NoError(t, err) + exists, err := store.VariationExists("project", "config", "prompt") + require.NoError(t, err) + assert.False(t, exists) + _, err = os.Stat(referencePath) + require.NoError(t, err) + manifest, exists, err := manifestStore.Load() + require.NoError(t, err) + require.True(t, exists) + assert.Empty(t, manifest.Resources) +} + +func TestDetachResourcesRemovesManifestEntryWhenWrapperWasAlreadyDeleted(t *testing.T) { + root := t.TempDir() + store := synclocal.NewStore(root) + manifestStore := syncmanifest.NewStore(root) + original := syncmanifest.Manifest{ + FormatVersion: syncmanifest.FormatVersion, + Resources: []syncmanifest.Resource{{ + ResourceKind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/deleted", + Fingerprint: testFingerprint(), + }}, + } + require.NoError(t, manifestStore.Write(original)) + + resource := Resource{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/deleted"} + err := detachResources(Options{Store: store, Manifest: manifestStore}, original, true, []Resource{resource}) + + require.NoError(t, err) + manifest, exists, err := manifestStore.Load() + require.NoError(t, err) + require.True(t, exists) + assert.Empty(t, manifest.Resources) +} + +func TestDetachResourcesDeletesUnreadableWrapper(t *testing.T) { + root := t.TempDir() + wrapper := filepath.Join(root, syncdomain.RootDir, "project", "configs", "config", "broken.prompt.md") + require.NoError(t, os.MkdirAll(filepath.Dir(wrapper), 0o755)) + require.NoError(t, os.WriteFile(wrapper, []byte("not front matter"), 0o644)) + + store := synclocal.NewStore(root) + manifestStore := syncmanifest.NewStore(root) + resource := Resource{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/broken"} + + err := detachResources( + Options{Store: store, Manifest: manifestStore}, + syncmanifest.New(), + false, + []Resource{resource}, + ) + + require.NoError(t, err) + manifest, exists, loadErr := manifestStore.Load() + require.NoError(t, loadErr) + require.True(t, exists) + assert.Empty(t, manifest.Resources) + _, statErr := os.Stat(wrapper) + require.ErrorIs(t, statErr, os.ErrNotExist) +} + +func TestRunRequiresTerminalWhenResourcesExist(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) + + err = Run(Options{ + RepositoryRoot: root, + Store: store, + Manifest: syncmanifest.NewStore(root), + Input: bytes.NewBuffer(nil), + Output: bytes.NewBuffer(nil), + }) + + require.ErrorContains(t, err, "requires a terminal") +} + +func TestRunReportsWhenNoResourcesAreSynced(t *testing.T) { + root := t.TempDir() + var output bytes.Buffer + + err := Run(Options{ + RepositoryRoot: root, + Store: synclocal.NewStore(root), + Manifest: syncmanifest.NewStore(root), + Input: bytes.NewBuffer(nil), + Output: &output, + }) + + require.NoError(t, err) + assert.Equal(t, "No resources are currently synced.\n", output.String()) +} + +func testVariation(key string) syncdomain.Variation { + return syncdomain.Variation{Mode: syncdomain.VariationModeAgent, Key: key, Name: key, Instructions: "Help."} +} + +func testFingerprint() string { + return "sha256:" + strings.Repeat("0", 64) +} diff --git a/internal/sync/link/link.go b/internal/sync/link/link.go new file mode 100644 index 00000000..3a450448 --- /dev/null +++ b/internal/sync/link/link.go @@ -0,0 +1,373 @@ +package link + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "unicode" + + "github.com/charmbracelet/huh" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + syncconsole "github.com/launchdarkly/ldcli/internal/sync/console" + syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" + syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" + "github.com/launchdarkly/ldcli/internal/sync/reference/adapters" +) + +// Catalog lists the LaunchDarkly resources required to link a prompt. +type Catalog interface { + Projects() ([]syncapi.Project, error) + Configs(projectKey string) ([]syncapi.Config, error) + ModelConfigs(projectKey string) ([]syncapi.ModelConfig, error) +} + +// Options contains the dependencies and inputs for one link operation. +type Options struct { + Catalog Catalog + Store synclocal.Store + RepositoryRoot string + WorkingDirectory string + File string + Format string + Input io.Reader + Output io.Writer +} + +// Selection is the LaunchDarkly destination selected for a linked prompt. +type Selection struct { + Project syncapi.Project + Config syncapi.Config + ModelConfig syncapi.ModelConfig + Key string + Name string +} + +type linkedPrompt struct { + reference synclocal.Reference + parsed adapters.Prompt + originalContent []byte + content []byte +} + +// Run interactively selects a destination and creates the local linked +// variation wrapper. +func Run(options Options) (string, error) { + if !syncinteractive.StreamsAreTerminal(options.Input, options.Output) { + return "", fmt.Errorf("interactive prompt linking requires a terminal; run this command in a terminal") + } + + prompt, err := readLinkedPrompt(options) + if err != nil { + return "", err + } + + console := syncconsole.New(options.Output) + _ = console.Line("Loading LaunchDarkly projects...") + projects, err := options.Catalog.Projects() + if err != nil { + return "", err + } + project, canceled, err := syncinteractive.Select( + options.Input, + options.Output, + "Choose a LaunchDarkly project", + projectChoices(projects), + ) + if err != nil || canceled { + return "", err + } + + _ = console.Line("Loading configs...") + configs, err := options.Catalog.Configs(project.Key) + if err != nil { + return "", err + } + configs = configsForPrompt(configs, prompt.parsed) + config, canceled, err := syncinteractive.Select( + options.Input, + options.Output, + "Choose a config", + configChoices(configs), + ) + if err != nil || canceled { + return "", err + } + + _ = console.Line("Loading model configs...") + modelConfigs, err := options.Catalog.ModelConfigs(project.Key) + if err != nil { + return "", err + } + modelConfig, canceled, err := syncinteractive.Select( + options.Input, + options.Output, + "Choose a model config", + modelConfigChoices(modelConfigs), + ) + if err != nil || canceled { + return "", err + } + + defaultKey := strings.TrimSuffix(filepath.Base(prompt.reference.File), filepath.Ext(prompt.reference.File)) + key := prompt.parsed.Key + var fields []huh.Field + if key == "" { + key = defaultKey + fields = append(fields, huh.NewInput(). + Title("Variation key"). + Value(&key). + Validate(validateDerivedKey)) + } + name := prompt.parsed.Name + if name == "" { + name = displayName(key) + fields = append(fields, huh.NewInput(). + Title("Variation name"). + Value(&name). + Validate(requiredValue("variation name"))) + } + var content string + if len(prompt.parsed.Messages) == 0 { + fields = append(fields, huh.NewText(). + Title("Prompt content"). + Lines(8). + Value(&content). + Validate(requiredValue("prompt content"))) + } + if len(fields) != 0 { + canceled, err = syncinteractive.RunForm(options.Input, options.Output, fields...) + if err != nil || canceled { + return "", 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) +} + +// addMissingPromptContent renders entered content in memory so destination +// validation can finish before the referenced file is changed. +func addMissingPromptContent( + prompt linkedPrompt, + mode syncdomain.VariationMode, + key string, + name string, + 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}} + } + 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. +func createLinkedPrompt(options Options, selection Selection, prompt linkedPrompt) (string, error) { + variation := syncdomain.Variation{ + Mode: selection.Config.Mode, + Key: selection.Key, + Name: selection.Name, + ModelConfigKey: selection.ModelConfig.Key, + ModelConfigVersion: selection.ModelConfig.Version, + Model: selection.ModelConfig.VariationModel(), + } + if _, err := syncreference.ApplyToVariation(prompt.reference.Format, prompt.content, &variation); err != nil { + return "", err + } + 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") + } + 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) + } + } + exists, err := options.Store.VariationExists(selection.Project.Key, selection.Config.Key, variation.Key) + if err != nil { + return "", err + } + if exists { + 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)) + sourceChanged := !bytes.Equal(prompt.originalContent, prompt.content) + var sourceMode os.FileMode + if sourceChanged { + info, err := os.Stat(target) + if err != nil { + return "", err + } + sourceMode = info.Mode().Perm() + if err := synclocal.ReplaceFileAtomically(target, prompt.reference.File, prompt.originalContent, prompt.content, sourceMode); err != nil { + return "", fmt.Errorf("write linked file %q: %w", prompt.reference.File, err) + } + } + + paths, err := options.Store.Add([]synclocal.VariationFile{{ + ProjectKey: selection.Project.Key, + ConfigKey: selection.Config.Key, + Upsert: true, + Ref: &prompt.reference, + Variation: variation, + }}) + if err != nil { + if sourceChanged { + err = errors.Join(err, restoreLinkedFile(target, prompt.reference.File, prompt.content, prompt.originalContent, sourceMode)) + } + return "", err + } + return paths[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. +func readLinkedPrompt(options Options) (linkedPrompt, error) { + reference, err := synclocal.NewReference(options.RepositoryRoot, options.WorkingDirectory, options.File, options.Format) + if err != nil { + return linkedPrompt{}, err + } + content, err := os.ReadFile(filepath.Join(options.RepositoryRoot, filepath.FromSlash(reference.File))) + if err != nil { + return linkedPrompt{}, fmt.Errorf("read linked file %q: %w", reference.File, err) + } + prompt, err := syncreference.Parse(reference.Format, content) + if err != nil { + return linkedPrompt{}, err + } + return linkedPrompt{reference: reference, parsed: prompt, originalContent: content, content: content}, nil +} + +// configsForPrompt limits destinations when the adapter supplied a mode. +func configsForPrompt(configs []syncapi.Config, prompt adapters.Prompt) []syncapi.Config { + if prompt.Mode == "" { + return configs + } + result := make([]syncapi.Config, 0, len(configs)) + for _, config := range configs { + if string(config.Mode) == string(prompt.Mode) { + result = append(result, config) + } + } + return result +} + +// validateDerivedKey ensures a filename-derived key is also a safe path segment. +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: + return fmt.Errorf("linked filename produces invalid variation key %q", key) + default: + return nil + } +} + +// requiredValue builds a reusable non-blank form validator. +func requiredValue(label string) func(string) error { + return func(value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s is required", label) + } + return nil + } +} + +// displayName turns a kebab- or snake-case key into a readable default name. +func displayName(key string) string { + name := strings.NewReplacer("-", " ", "_", " ").Replace(key) + runes := []rune(name) + if len(runes) != 0 { + runes[0] = unicode.ToUpper(runes[0]) + } + return string(runes) +} + +// projectChoices adapts projects to interactive labels. +func projectChoices(projects []syncapi.Project) []syncinteractive.Choice[syncapi.Project] { + choices := make([]syncinteractive.Choice[syncapi.Project], 0, len(projects)) + for _, project := range projects { + choices = append(choices, syncinteractive.Choice[syncapi.Project]{ + Title: project.Name, Description: project.Key, Value: project, + }) + } + return choices +} + +// configChoices adapts configs to labels that expose key and mode. +func configChoices(configs []syncapi.Config) []syncinteractive.Choice[syncapi.Config] { + choices := make([]syncinteractive.Choice[syncapi.Config], 0, len(configs)) + for _, config := range configs { + choices = append(choices, syncinteractive.Choice[syncapi.Config]{ + Title: config.Name, Description: fmt.Sprintf("%s ยท %s", config.Key, config.Mode), Value: config, + }) + } + return choices +} + +// 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: config.Key, Value: config, + }) + } + return choices +} diff --git a/internal/sync/link/link_test.go b/internal/sync/link/link_test.go new file mode 100644 index 00000000..dbe1a66a --- /dev/null +++ b/internal/sync/link/link_test.go @@ -0,0 +1,160 @@ +package link + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" + syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" +) + +func TestCreateWritesLinkedVariationWrapper(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{ + Store: synclocal.NewStore(root), + RepositoryRoot: root, + WorkingDirectory: root, + File: "prompts/support-agent.md", + Format: syncreference.PlainMarkdown, + }, Selection{ + Project: syncapi.Project{Key: "production", Name: "Production"}, + Config: syncapi.Config{Key: "support", Name: "Support", Mode: syncdomain.VariationModeAgent}, + ModelConfig: syncapi.ModelConfig{ + Key: "claude", ID: "claude-3-5-sonnet-20241022", Name: "Claude", Version: 4, + Params: map[string]any{"temperature": 0.2}, CustomParams: map[string]any{"region": "us-east"}, + }, + Key: "support-agent", + Name: "Support agent", + }) + + 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.Equal(t, `--- +formatVersion: 1 +upsert: true +ref: + file: prompts/support-agent.md + format: plain-markdown +mode: agent +key: support-agent +name: Support agent +modelConfigKey: claude +modelConfigVersion: 4 +model: + custom: + region: us-east + modelName: claude-3-5-sonnet-20241022 + parameters: + temperature: 0.2 +--- +`, string(wrapper)) + + 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"`) +} + +func TestCreateRejectsExistingServerVariation(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Help"), 0o644)) + + _, err := Create(Options{ + Store: synclocal.NewStore(root), + RepositoryRoot: root, + WorkingDirectory: root, + File: "prompt.md", + Format: syncreference.PlainMarkdown, + }, Selection{ + Project: syncapi.Project{Key: "production"}, + Config: syncapi.Config{ + Key: "support", Mode: syncdomain.VariationModeAgent, + Variations: []syncdomain.Variation{{Key: "prompt"}}, + }, + ModelConfig: syncapi.ModelConfig{Key: "claude", ID: "claude-3-5-sonnet-20241022"}, + Key: "prompt", + Name: "Prompt", + }) + + require.ErrorContains(t, err, `variation "prompt" already exists`) +} + +func TestCreateValidatesDestinationBeforeUpdatingLinkedFile(t *testing.T) { + root := t.TempDir() + sourcePath := filepath.Join(root, "prompt.md") + require.NoError(t, os.WriteFile(sourcePath, nil, 0o644)) + options := Options{ + Store: synclocal.NewStore(root), + RepositoryRoot: root, + WorkingDirectory: root, + File: "prompt.md", + Format: syncreference.PlainMarkdown, + } + prompt, err := readLinkedPrompt(options) + require.NoError(t, err) + prompt, err = addMissingPromptContent(prompt, syncdomain.VariationModeAgent, "prompt", "Prompt", "Help.") + require.NoError(t, err) + + _, err = createLinkedPrompt(options, Selection{ + Project: syncapi.Project{Key: "production"}, + Config: syncapi.Config{ + Key: "support", Mode: syncdomain.VariationModeAgent, + Variations: []syncdomain.Variation{{Key: "prompt"}}, + }, + ModelConfig: syncapi.ModelConfig{Key: "claude", ID: "claude-3-5-sonnet-20241022"}, + Key: "prompt", + Name: "Prompt", + }, prompt) + + require.ErrorContains(t, err, `variation "prompt" already exists`) + content, readErr := os.ReadFile(sourcePath) + require.NoError(t, readErr) + require.Empty(t, content) +} + +func TestCreateDoesNotOverwriteLinkedFileChangedAfterRead(t *testing.T) { + root := t.TempDir() + sourcePath := filepath.Join(root, "prompt.md") + require.NoError(t, os.WriteFile(sourcePath, nil, 0o644)) + options := Options{ + Store: synclocal.NewStore(root), + RepositoryRoot: root, + WorkingDirectory: root, + File: "prompt.md", + Format: syncreference.PlainMarkdown, + } + prompt, err := readLinkedPrompt(options) + require.NoError(t, err) + prompt.content = []byte("content collected by the linker") + + require.NoError(t, os.WriteFile(sourcePath, []byte("newer content from the editor"), 0o644)) + + _, err = createLinkedPrompt(options, Selection{ + Project: syncapi.Project{Key: "production"}, + Config: syncapi.Config{Key: "support", Mode: syncdomain.VariationModeAgent}, + ModelConfig: syncapi.ModelConfig{Key: "claude", ID: "claude-3-5-sonnet-20241022"}, + Key: "prompt", + Name: "Prompt", + }, prompt) + require.ErrorContains(t, err, "changed while syncing") + + content, readErr := os.ReadFile(sourcePath) + require.NoError(t, readErr) + require.Equal(t, "newer content from the editor", string(content)) +} + +func TestRequiredValueRejectsBlankInput(t *testing.T) { + require.Error(t, requiredValue("variation name")(" ")) + require.NoError(t, requiredValue("variation name")("Custom name")) +} diff --git a/internal/sync/local/replace.go b/internal/sync/local/replace.go index 5f4a7f8a..fc87dcc8 100644 --- a/internal/sync/local/replace.go +++ b/internal/sync/local/replace.go @@ -172,6 +172,29 @@ func replacementPaths(replacements []stagedVariation) []string { 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) {