Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 0 additions & 55 deletions cmd/sync/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ 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"
)

Expand Down Expand Up @@ -68,7 +67,6 @@ func NewPromptCmd(client resources.Client) *cobra.Command {
newAddCmd(client),
newAttachCmd(client),
newDetachCmd(client),
newLinkCmd(client),
)
cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate())

Expand Down Expand Up @@ -270,37 +268,6 @@ func newDetachCmd(client resources.Client) *cobra.Command {
return cmd
}

func newLinkCmd(client resources.Client) *cobra.Command {
cmd := &cobra.Command{
Use: "link <file>",
Short: "Link an external prompt file",
Example: ` # Link a Markdown file as a new variation
ldcli sync prompts link prompts/support.md \
--format plain-markdown \
--to production/support/default \
--model-config-key claude \
--yes --no-input`,
Args: validatedArgs(cobra.ExactArgs(1)),
RunE: runCommand(client, syncdomain.ScopePrompts, 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, ProjectKey: targetFilter(cmd).Project, Target: target}, nil
}),
}
addTargetFlags(cmd, targetFlagHelp{project: questionFlagHelp.project})
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, syncdomain.ScopePrompts, false)
return cmd
}

// 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) {
Expand Down Expand Up @@ -434,28 +401,6 @@ func optionalVariationFlag(cmd *cobra.Command, name string) (*syncdomain.Resourc
return &variation, nil
}

func linkTarget(cmd *cobra.Command) (*synclink.Target, error) {
variation, err := optionalVariationFlag(cmd, toFlag)
if err != nil {
return nil, err
}
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 {
Expand Down
10 changes: 2 additions & 8 deletions cmd/sync/prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,17 @@ func TestPromptCommandUsesActionSubcommands(t *testing.T) {
command := NewPromptCmd(nil)

assert.Equal(t, "prompts", command.Use)
for _, name := range []string{"watch", "add", "attach", "detach", "link"} {
for _, name := range []string{"watch", "add", "attach", "detach"} {
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"} {
for _, name := range []string{"add", "attach-tool", "attach-skill", "detach", "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) {
Expand All @@ -57,7 +53,6 @@ func TestPromptCommandHelpIncludesExamples(t *testing.T) {
"attach tool": {"attach", "tool"},
"attach skill": {"attach", "skill"},
"detach": {"detach"},
"link": {"link"},
}

for name, path := range tests {
Expand Down Expand Up @@ -112,7 +107,6 @@ 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: targetFlagHelp{project: questionFlagHelp.project}},
}

for name, test := range tests {
Expand Down
25 changes: 9 additions & 16 deletions cmd/sync/scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,14 @@ 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 prompts link": {
"conflict", "content", "diff", "format", "model-config-key", "name", "no-input", "project", "resolve", "to", "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 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"},
}

func TestSyncCommandsHaveExactFlags(t *testing.T) {
Expand Down Expand Up @@ -75,11 +72,7 @@ func TestSyncCommandsReadSyncFlagsOnlyFromTheCommandLine(t *testing.T) {

for _, command := range runnableSyncCommands(NewSyncCmd(nil, nil)) {
require.NoError(t, command.ParseFlags(nil))
var args []string
if command.Name() == "link" {
args = []string{"prompt.md"}
}
require.NoError(t, command.Args(command, args), command.CommandPath())
require.NoError(t, command.Args(command, nil), command.CommandPath())
command.LocalNonPersistentFlags().VisitAll(func(flag *pflag.Flag) {
assert.False(t, flag.Changed, "%s --%s", command.CommandPath(), flag.Name)
})
Expand Down
16 changes: 16 additions & 0 deletions internal/sync/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ func TestClientArchiveVariationReturnsTransportError(t *testing.T) {

func TestClientGetsSyncManifest(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{[]byte(`{
"formatVersion": 1,
"source": "git:example.com/acme/repo",
"items": [{
"resourceKind": "variation",
Expand All @@ -487,6 +488,7 @@ func TestClientGetsSyncManifest(t *testing.T) {

func TestClientPatchesSyncManifest(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{[]byte(`{
"formatVersion": 1,
"source": "git:example.com/acme/repo",
"items": []
}`)}}
Expand All @@ -508,6 +510,7 @@ func TestClientPatchesSyncManifest(t *testing.T) {
assert.Equal(t, http.MethodPatch, request.Method)
assert.Equal(t, "application/json", request.ContentType)
assert.JSONEq(t, `{
"formatVersion": 1,
"source": "git:example.com/acme/repo",
"upserts": [{
"resourceKind": "variation",
Expand All @@ -519,6 +522,19 @@ func TestClientPatchesSyncManifest(t *testing.T) {
}`, string(request.Body))
}

func TestClientRejectsUnknownSyncManifestVersion(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{[]byte(`{
"formatVersion": 99,
"source": "git:example.com/acme/repo",
"items": []
}`)}}
client := NewClient(transport, "token", "https://example.com")

_, err := client.GetSyncManifest("project", "git:example.com/acme/repo")

require.ErrorContains(t, err, "unsupported formatVersion 99")
}

func TestIsConflictRecognizesAPIStatus(t *testing.T) {
assert.True(t, IsConflict(errors.New(`{"code":"conflict","statusCode":409}`)))
assert.False(t, IsConflict(errors.New(`{"code":"invalid_request","statusCode":400}`)))
Expand Down
65 changes: 50 additions & 15 deletions internal/sync/api/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,39 @@ import (
syncdomain "github.com/launchdarkly/ldcli/internal/sync"
)

// SyncManifestFormatVersion is the remote sync manifest schema.
const SyncManifestFormatVersion = 1

// 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"`
FormatVersion int `json:"formatVersion"`
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"`
ResourceKind syncdomain.Kind `json:"resourceKind"`
ResourceLookupKey string `json:"resourceLookupKey"`
Fingerprint string `json:"fingerprint"`
Version int `json:"version"`
FingerprintSchema string `json:"fingerprintSchema,omitempty"`
ReferenceDigest string `json:"referenceDigest,omitempty"`
AdapterBehaviorVersion int `json:"adapterBehaviorVersion,omitempty"`
AdapterBehaviorDigest string `json:"adapterBehaviorDigest,omitempty"`
}

// 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"`
ResourceKind syncdomain.Kind `json:"resourceKind"`
ResourceLookupKey string `json:"resourceLookupKey"`
Fingerprint string `json:"fingerprint"`
Version int `json:"version"`
FingerprintSchema string `json:"fingerprintSchema,omitempty"`
ReferenceDigest string `json:"referenceDigest,omitempty"`
AdapterBehaviorVersion int `json:"adapterBehaviorVersion,omitempty"`
AdapterBehaviorDigest string `json:"adapterBehaviorDigest,omitempty"`
}

// SyncManifestDeletion removes the entry at Version.
Expand All @@ -48,7 +60,14 @@ func (client Client) GetSyncManifest(projectKey, source string) (SyncManifest, e
if err != nil {
return SyncManifest{}, err
}
return decodeJSON[SyncManifest](response, fmt.Sprintf("sync manifest for project %q", projectKey))
manifest, err := decodeJSON[SyncManifest](response, fmt.Sprintf("sync manifest for project %q", projectKey))
if err != nil {
return SyncManifest{}, err
}
if err := validateSyncManifest(manifest); err != nil {
return SyncManifest{}, fmt.Errorf("sync manifest for project %q: %w", projectKey, err)
}
return manifest, nil
}

// PatchSyncManifest applies versioned changes and returns the new baseline.
Expand All @@ -68,10 +87,16 @@ func (client Client) PatchSyncManifest(
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},
FormatVersion int `json:"formatVersion"`
Source string `json:"source"`
Upserts []SyncManifestUpsert `json:"upserts"`
Deletions []SyncManifestDeletion `json:"deletions"`
}{
FormatVersion: SyncManifestFormatVersion,
Source: source,
Upserts: upserts,
Deletions: deletions,
},
}
response, err := client.mutate(request, manifestPath(projectKey)...)
if err != nil {
Expand All @@ -84,9 +109,19 @@ func (client Client) PatchSyncManifest(
// caller must read the manifest again to find its state.
return SyncManifest{}, newMutationError("decode updated", request.resource, projectKey, err)
}
if err := validateSyncManifest(manifest); err != nil {
return SyncManifest{}, newMutationError("decode updated", request.resource, projectKey, err)
}
return manifest, nil
}

func validateSyncManifest(manifest SyncManifest) error {
if manifest.FormatVersion != SyncManifestFormatVersion {
return fmt.Errorf("unsupported formatVersion %d", manifest.FormatVersion)
}
return nil
}

func manifestPath(projectKey string) []string {
return projectPath(projectKey, "configs/sync/manifests")
}
5 changes: 5 additions & 0 deletions internal/sync/attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ func (attachment Attachment) ID(projectKey string) ResourceID {
type SyncedAttachment struct {
ProjectKey string
Attachment Attachment
// Source is the descriptor-backed repository source, or nil for a managed file.
Source *Reference
Dependencies []string
IndexedSelections []IndexedSelection
Projection Projection
}

// ID returns the identity of the tool or the skill.
Expand Down
43 changes: 0 additions & 43 deletions internal/sync/detach/detach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import (
syncapi "github.com/launchdarkly/ldcli/internal/sync/api"
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) {
Expand Down Expand Up @@ -97,48 +96,6 @@ func TestDetachResourcesKeepsAttachmentManifestEntries(t *testing.T) {
require.NoError(t, err, "the tool file stays")
}

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 := newMemoryManifestStore()
original := syncmanifest.Manifest{
Resources: []syncmanifest.Resource{{
ResourceKind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/prompt",
Fingerprint: testFingerprint(),
}},
}
require.NoError(t, manifestStore.Write(original))

resource := syncdomain.ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/prompt"}
err = detachResources(
Options{RepositoryRoot: root, Store: store, Baselines: manifestStore},
syncmanifest.Baseline{Lock: original},
[]syncdomain.ResourceID{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)
baseline, err := manifestStore.Load([]string{"project"})
manifest := baseline.Lock
require.NoError(t, err)
assert.Empty(t, manifest.Resources)
}

func TestDetachResourcesRemovesManifestEntryWhenWrapperWasAlreadyDeleted(t *testing.T) {
root := t.TempDir()
store := synclocal.NewStore(root)
Expand Down
Loading
Loading