From 47bb7968a5fc5c121d35646901601401ebedf5bb Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Wed, 9 Sep 2026 20:07:53 -0400 Subject: [PATCH 1/5] feat(sync): add prompt status command Validate the repository, compile local resources, and check their status per project through a typed sync API client. Keep request debugging isolated behind a temporary flag. --- cmd/root.go | 2 + cmd/root_test.go | 1 + cmd/sync/debug.go | 41 ++++++ cmd/sync/prompt.go | 75 +++++++++++ cmd/sync/prompt_test.go | 238 +++++++++++++++++++++++++++++++++++ cmd/sync/sync.go | 37 ++++++ cmd/templates.go | 1 + cmd/templates_test.go | 1 + internal/sync/client.go | 140 +++++++++++++++++++++ internal/sync/client_test.go | 127 +++++++++++++++++++ 10 files changed, 663 insertions(+) create mode 100644 cmd/sync/debug.go create mode 100644 cmd/sync/prompt.go create mode 100644 cmd/sync/prompt_test.go create mode 100644 cmd/sync/sync.go create mode 100644 internal/sync/client.go create mode 100644 internal/sync/client_test.go diff --git a/cmd/root.go b/cmd/root.go index a8f2110e..ad33ea59 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,6 +28,7 @@ import ( signupcmd "github.com/launchdarkly/ldcli/cmd/signup" sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps" symbolscmd "github.com/launchdarkly/ldcli/cmd/symbols" + synccmd "github.com/launchdarkly/ldcli/cmd/sync" whoamicmd "github.com/launchdarkly/ldcli/cmd/whoami" "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/config" @@ -299,6 +300,7 @@ func NewRootCommand( cmd.AddCommand(devcmd.NewDevServerCmd(clients.ResourcesClient, analyticsTrackerFn, clients.DevClient)) cmd.AddCommand(sourcemapscmd.NewSourcemapsCmd(clients.ResourcesClient, analyticsTrackerFn)) cmd.AddCommand(symbolscmd.NewSymbolsCmd(clients.ResourcesClient, analyticsTrackerFn)) + cmd.AddCommand(synccmd.NewSyncCmd(clients.ResourcesClient, analyticsTrackerFn)) cmd.AddCommand(whoamicmd.NewWhoAmICmd(clients.ResourcesClient)) resourcecmd.AddAllResourceCmds(cmd, clients.ResourcesClient, analyticsTrackerFn) diff --git a/cmd/root_test.go b/cmd/root_test.go index 2b34ee65..d8132e2d 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -367,6 +367,7 @@ func TestNewRootCommand_RegistersTopLevelCommands(t *testing.T) { "signup", "sourcemaps", "symbols", + "sync", "whoami", } { assert.True(t, registered[name], "%s is not registered on the root command", name) diff --git a/cmd/sync/debug.go b/cmd/sync/debug.go new file mode 100644 index 00000000..82abd3f8 --- /dev/null +++ b/cmd/sync/debug.go @@ -0,0 +1,41 @@ +package sync + +import ( + "fmt" + "io" + "net/url" + + "github.com/launchdarkly/ldcli/internal/resources" +) + +type debugClient struct { + next resources.Client + out io.Writer +} + +func (c debugClient) MakeRequest( + accessToken string, + method string, + endpoint string, + contentType string, + query url.Values, + body []byte, + isBeta bool, +) ([]byte, error) { + writeRequestDebug(c.out, method, endpoint, body) + + return c.next.MakeRequest(accessToken, method, endpoint, contentType, query, body, isBeta) +} + +func (c debugClient) MakeUnauthenticatedRequest(method, endpoint string, body []byte) ([]byte, error) { + return c.next.MakeUnauthenticatedRequest(method, endpoint, body) +} + +func writeRequestDebug(out io.Writer, method, endpoint string, body []byte) { + path := endpoint + if parsed, err := url.Parse(endpoint); err == nil { + path = parsed.RequestURI() + } + + fmt.Fprintf(out, "HTTP request\nMethod: %s\nPath: %s\nBody:\n%s\n", method, path, body) +} diff --git a/cmd/sync/prompt.go b/cmd/sync/prompt.go new file mode 100644 index 00000000..dff30129 --- /dev/null +++ b/cmd/sync/prompt.go @@ -0,0 +1,75 @@ +package sync + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/cmd/validators" + "github.com/launchdarkly/ldcli/internal/output" + "github.com/launchdarkly/ldcli/internal/resources" + syncapi "github.com/launchdarkly/ldcli/internal/sync" +) + +const debugFlag = "debug" + +func NewPromptCmd(client resources.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "prompt", + Short: "Check synchronization status for local prompts", + Long: "Read local prompt resources and check their synchronization status with LaunchDarkly.", + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.NoArgs(cmd, args); err != nil { + return err + } + return validators.Validate()(cmd, args) + }, + RunE: runPrompt(client), + } + + cmd.Flags().Bool(debugFlag, false, "Print the status request method, path, and body") + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + + return cmd +} + +func runPrompt(client resources.Client) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, _ []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + + repo, err := syncapi.IdentifyRepo(cwd) + if err != nil { + return err + } + + localResources, err := syncapi.Compile(os.DirFS(repo.Root)) + if err != nil { + return err + } + + requestClient := client + debug, _ := cmd.Flags().GetBool(debugFlag) + if debug { + requestClient = debugClient{next: client, out: cmd.ErrOrStderr()} + } + + _, err = syncapi.NewAPIClient(requestClient).Status( + viper.GetString(cliflags.AccessTokenFlag), + viper.GetString(cliflags.BaseURIFlag), + repo.Identifier, + localResources, + ) + if err != nil { + return output.NewCmdOutputError(err, cliflags.GetOutputKind(cmd)) + } + + return nil + } +} diff --git a/cmd/sync/prompt_test.go b/cmd/sync/prompt_test.go new file mode 100644 index 00000000..9e68cd6e --- /dev/null +++ b/cmd/sync/prompt_test.go @@ -0,0 +1,238 @@ +package sync_test + +import ( + "encoding/json" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/cmd" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/resources" +) + +type recordedRequest struct { + Method string + Path string + ContentType string + Body []byte +} + +type recordingClient struct { + Requests []recordedRequest + Responses [][]byte +} + +var _ resources.Client = &recordingClient{} + +func (c *recordingClient) MakeRequest( + _ string, + method string, + path string, + contentType string, + _ url.Values, + body []byte, + _ bool, +) ([]byte, error) { + c.Requests = append(c.Requests, recordedRequest{ + Method: method, + Path: path, + ContentType: contentType, + Body: append([]byte(nil), body...), + }) + + response := c.Responses[len(c.Requests)-1] + return response, nil +} + +func (*recordingClient) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { + return nil, nil +} + +func TestPromptStatus(t *testing.T) { + repo := initRepo(t) + writePrompt(t, repo, "proj", "config", "hello", true) + t.Chdir(repo) + + client := &recordingClient{ + Responses: [][]byte{[]byte(`[ + { + "resourceKind": "variation", + "lookupKey": "config/hello", + "status": "local_changed", + "syncDirection": "code_canonical", + "serverFingerprint": "sha256.server", + "error": null + } + ]`)}, + } + + stdout, stderr, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--access-token", "token", + "--base-uri", "https://example.com", + "--debug", + }, + ) + require.NoError(t, err) + require.Len(t, client.Requests, 1) + + request := client.Requests[0] + assert.Equal(t, "POST", request.Method) + assert.Equal(t, "https://example.com/api/v2/projects/proj/ai-configs/sync/status", request.Path) + assert.Equal(t, "application/json", request.ContentType) + + var body struct { + RepoIdentifier string `json:"repoIdentifier"` + Resources []struct { + Fingerprint string `json:"fingerprint"` + ResourceKind string `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Upsert bool `json:"upsert"` + } `json:"resources"` + } + require.NoError(t, json.Unmarshal(request.Body, &body)) + assert.Equal(t, "launchdarkly/ldcli", body.RepoIdentifier) + require.Len(t, body.Resources, 1) + assert.Regexp(t, `^sha256\.[0-9a-f]{64}$`, body.Resources[0].Fingerprint) + assert.Equal(t, "variation", body.Resources[0].ResourceKind) + assert.Equal(t, "config/hello", body.Resources[0].LookupKey) + assert.True(t, body.Resources[0].Upsert) + assert.NotContains(t, string(request.Body), "createIfMissing") + + assert.Empty(t, stdout) + + assert.Contains(t, string(stderr), "Method: POST") + assert.Contains(t, string(stderr), "Path: /api/v2/projects/proj/ai-configs/sync/status") + assert.Contains(t, string(stderr), `"repoIdentifier": "launchdarkly/ldcli"`) + assert.NotContains(t, string(stderr), "token") +} + +func TestPromptStatus_DoesNotPrintStatuses(t *testing.T) { + repo := initRepo(t) + writePrompt(t, repo, "proj", "config", "hello", false) + t.Chdir(repo) + + client := &recordingClient{ + Responses: [][]byte{[]byte(`[ + { + "resourceKind": "variation", + "lookupKey": "config/hello", + "status": "in_sync", + "syncDirection": "code_canonical" + } + ]`)}, + } + + stdout, stderr, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--access-token", "token", + "--base-uri", "https://example.com", + "--output", "json", + }, + ) + require.NoError(t, err) + assert.Empty(t, stdout) + assert.Empty(t, stderr) +} + +func TestPromptStatus_GroupsRequestsByProject(t *testing.T) { + repo := initRepo(t) + writePrompt(t, repo, "alpha", "config", "first", false) + writePrompt(t, repo, "zeta", "config", "second", false) + t.Chdir(repo) + + client := &recordingClient{ + Responses: [][]byte{ + []byte(`[{"resourceKind":"variation","lookupKey":"config/first","status":"in_sync","syncDirection":"code_canonical"}]`), + []byte(`[{"resourceKind":"variation","lookupKey":"config/second","status":"in_sync","syncDirection":"code_canonical"}]`), + }, + } + + _, _, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--access-token", "token", + "--base-uri", "https://example.com", + }, + ) + require.NoError(t, err) + require.Len(t, client.Requests, 2) + assert.Contains(t, client.Requests[0].Path, "/projects/alpha/") + assert.Contains(t, client.Requests[1].Path, "/projects/zeta/") +} + +func TestPromptStatus_NoResources(t *testing.T) { + repo := initRepo(t) + require.NoError(t, os.MkdirAll(filepath.Join(repo, ".launchdarkly", "proj"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(repo, ".launchdarkly", "proj", ".keep"), nil, 0o644)) + t.Chdir(repo) + + client := &recordingClient{} + stdout, _, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--access-token", "token", + "--output", "json", + }, + ) + require.NoError(t, err) + assert.Empty(t, stdout) + assert.Empty(t, client.Requests) +} + +func initRepo(t *testing.T) string { + t.Helper() + + root := t.TempDir() + runGit(t, root, "init", "--quiet") + runGit(t, root, "remote", "add", "origin", "git@github.com:launchdarkly/ldcli.git") + + return root +} + +func writePrompt(t *testing.T, root, project, config, key string, upsert bool) { + t.Helper() + + dir := filepath.Join(root, ".launchdarkly", project, "configs", config) + require.NoError(t, os.MkdirAll(dir, 0o755)) + + contents := []byte(`--- +formatVersion: 1 +upsert: ` + strconv.FormatBool(upsert) + ` +key: ` + key + ` +name: Test prompt +--- +Say hello. +`) + require.NoError(t, os.WriteFile(filepath.Join(dir, key+".prompt"), contents, 0o644)) +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + + command := exec.Command("git", args...) + command.Dir = dir + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) +} diff --git a/cmd/sync/sync.go b/cmd/sync/sync.go new file mode 100644 index 00000000..6e338d2a --- /dev/null +++ b/cmd/sync/sync.go @@ -0,0 +1,37 @@ +package sync + +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" + + cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics" + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/resources" +) + +func NewSyncCmd(client resources.Client, analyticsTrackerFn analytics.TrackerFn) *cobra.Command { + cmd := &cobra.Command{ + Use: "sync", + Short: "Synchronize local resources with LaunchDarkly", + Args: cobra.MinimumNArgs(1), + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + tracker := analyticsTrackerFn( + viper.GetString(cliflags.AccessTokenFlag), + viper.GetString(cliflags.BaseURIFlag), + viper.GetBool(cliflags.AnalyticsOptOut), + ) + tracker.SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties( + cmd, + "sync", + map[string]interface{}{"action": cmd.Name()}, + )) + }, + } + + cmd.AddCommand(NewPromptCmd(client)) + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + + return cmd +} diff --git a/cmd/templates.go b/cmd/templates.go index 46a5c1f2..d21a3186 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -19,6 +19,7 @@ Commands: {{rpad "login" 29}} Log in to your LaunchDarkly account {{rpad "signup" 29}} Create a new LaunchDarkly account {{rpad "dev-server" 29}} Run a development server to serve flags locally + {{rpad "sync" 29}} Synchronize local resources with LaunchDarkly Common resource commands: {{rpad "flags" 29}} List, create, and modify feature flags and their targeting diff --git a/cmd/templates_test.go b/cmd/templates_test.go index a4717eb3..7bf16f9a 100644 --- a/cmd/templates_test.go +++ b/cmd/templates_test.go @@ -19,6 +19,7 @@ func TestGetUsageTemplate_ListsTopLevelCommands(t *testing.T) { "login", "signup", "dev-server", + "sync", "flags", "environments", "projects", diff --git a/internal/sync/client.go b/internal/sync/client.go new file mode 100644 index 00000000..1af256fd --- /dev/null +++ b/internal/sync/client.go @@ -0,0 +1,140 @@ +package sync + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/launchdarkly/ldcli/internal/resources" +) + +type APIClient struct { + client resources.Client +} + +type ResourceStatus struct { + ProjectKey string `json:"projectKey"` + ResourceKind Kind `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Status string `json:"status"` + SyncDirection string `json:"syncDirection"` + ServerFingerprint Fingerprint `json:"serverFingerprint,omitempty"` + Error any `json:"error,omitempty"` +} + +type statusRequest struct { + RepoIdentifier string `json:"repoIdentifier"` + Resources []statusRequestResource `json:"resources"` +} + +type statusRequestResource struct { + Fingerprint Fingerprint `json:"fingerprint"` + ResourceKind Kind `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Upsert bool `json:"upsert"` +} + +type projectResources struct { + ProjectKey string + Resources []SyncedResource +} + +func NewAPIClient(client resources.Client) APIClient { + return APIClient{client: client} +} + +func (c APIClient) Status( + accessToken string, + baseURI string, + repoIdentifier string, + synced []SyncedResource, +) ([]ResourceStatus, error) { + statuses := make([]ResourceStatus, 0, len(synced)) + + for _, project := range groupResourcesByProject(synced) { + projectStatuses, err := c.projectStatus(accessToken, baseURI, repoIdentifier, project) + if err != nil { + return nil, err + } + statuses = append(statuses, projectStatuses...) + } + + return statuses, nil +} + +func (c APIClient) projectStatus( + accessToken string, + baseURI string, + repoIdentifier string, + project projectResources, +) ([]ResourceStatus, error) { + request := statusRequest{ + RepoIdentifier: repoIdentifier, + Resources: make([]statusRequestResource, 0, len(project.Resources)), + } + for _, resource := range project.Resources { + request.Resources = append(request.Resources, statusRequestResource{ + Fingerprint: resource.Fingerprint, + ResourceKind: resource.Kind, + LookupKey: resource.LookupKey, + Upsert: resource.Upsert, + }) + } + + body, err := json.MarshalIndent(request, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal status request: %w", err) + } + + endpoint, err := url.JoinPath( + baseURI, + "api/v2/projects", + project.ProjectKey, + "ai-configs/sync/status", + ) + if err != nil { + return nil, fmt.Errorf("build status endpoint: %w", err) + } + + response, err := c.client.MakeRequest( + accessToken, + http.MethodPost, + endpoint, + "application/json", + nil, + body, + false, + ) + if err != nil { + return nil, err + } + + var statuses []ResourceStatus + if err := json.Unmarshal(response, &statuses); err != nil { + return nil, fmt.Errorf("decode status response: %w", err) + } + + for i := range statuses { + statuses[i].ProjectKey = project.ProjectKey + } + + return statuses, nil +} + +func groupResourcesByProject(synced []SyncedResource) []projectResources { + var projects []projectResources + byProject := make(map[string]int) + + for _, resource := range synced { + index, ok := byProject[resource.ProjectKey] + if !ok { + index = len(projects) + byProject[resource.ProjectKey] = index + projects = append(projects, projectResources{ProjectKey: resource.ProjectKey}) + } + projects[index].Resources = append(projects[index].Resources, resource) + } + + return projects +} diff --git a/internal/sync/client_test.go b/internal/sync/client_test.go new file mode 100644 index 00000000..8a44c32d --- /dev/null +++ b/internal/sync/client_test.go @@ -0,0 +1,127 @@ +package sync + +import ( + "encoding/json" + "errors" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/resources" +) + +type apiRequest struct { + AccessToken string + Method string + Path string + Body []byte +} + +type apiClientStub struct { + Requests []apiRequest + Responses [][]byte + Err error +} + +var _ resources.Client = &apiClientStub{} + +func (c *apiClientStub) MakeRequest( + accessToken string, + method string, + path string, + _ string, + _ url.Values, + body []byte, + _ bool, +) ([]byte, error) { + c.Requests = append(c.Requests, apiRequest{ + AccessToken: accessToken, + Method: method, + Path: path, + Body: append([]byte(nil), body...), + }) + if c.Err != nil { + return nil, c.Err + } + + return c.Responses[len(c.Requests)-1], nil +} + +func (*apiClientStub) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { + return nil, nil +} + +func TestAPIClient_Status(t *testing.T) { + transport := &apiClientStub{ + Responses: [][]byte{ + []byte(`[{"resourceKind":"variation","lookupKey":"config/first","status":"local_changed","syncDirection":"code_canonical"}]`), + []byte(`[{"resourceKind":"tool","lookupKey":"search/2","status":"in_sync","syncDirection":"both"}]`), + }, + } + client := NewAPIClient(transport) + + statuses, err := client.Status( + "token", + "https://example.com", + "launchdarkly/ldcli", + []SyncedResource{ + { + ProjectKey: "alpha", + Kind: KindVariation, + LookupKey: "config/first", + Fingerprint: "sha256.first", + Upsert: true, + }, + { + ProjectKey: "zeta", + Kind: KindTool, + LookupKey: "search/2", + Fingerprint: "sha256.second", + }, + }, + ) + require.NoError(t, err) + require.Len(t, transport.Requests, 2) + require.Len(t, statuses, 2) + + assert.Equal(t, "POST", transport.Requests[0].Method) + assert.Equal(t, "token", transport.Requests[0].AccessToken) + assert.Equal(t, "https://example.com/api/v2/projects/alpha/ai-configs/sync/status", transport.Requests[0].Path) + assert.Equal(t, "alpha", statuses[0].ProjectKey) + assert.Equal(t, "zeta", statuses[1].ProjectKey) + + var request statusRequest + require.NoError(t, json.Unmarshal(transport.Requests[0].Body, &request)) + assert.Equal(t, "launchdarkly/ldcli", request.RepoIdentifier) + require.Len(t, request.Resources, 1) + assert.Equal(t, KindVariation, request.Resources[0].ResourceKind) + assert.Equal(t, "config/first", request.Resources[0].LookupKey) + assert.Equal(t, Fingerprint("sha256.first"), request.Resources[0].Fingerprint) + assert.True(t, request.Resources[0].Upsert) +} + +func TestAPIClient_StatusTransportError(t *testing.T) { + client := NewAPIClient(&apiClientStub{Err: errors.New("unavailable")}) + + _, err := client.Status( + "token", + "https://example.com", + "launchdarkly/ldcli", + []SyncedResource{{ProjectKey: "proj"}}, + ) + require.ErrorContains(t, err, "unavailable") +} + +func TestAPIClient_StatusInvalidResponse(t *testing.T) { + client := NewAPIClient(&apiClientStub{Responses: [][]byte{[]byte(`not json`)}}) + + _, err := client.Status( + "token", + "https://example.com", + "launchdarkly/ldcli", + []SyncedResource{{ProjectKey: "proj"}}, + ) + require.ErrorContains(t, err, "decode status response") +} From 482af553969fa7acf37f03123d746dde3c6f529e Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Fri, 11 Sep 2026 19:07:34 -0400 Subject: [PATCH 2/5] feat(sync): add variation planning client --- cmd/root.go | 2 - cmd/root_test.go | 1 - cmd/sync/debug.go | 41 ------ cmd/sync/prompt.go | 75 ---------- cmd/sync/prompt_test.go | 238 ------------------------------- cmd/sync/sync.go | 37 ----- cmd/templates.go | 1 - cmd/templates_test.go | 1 - internal/sync/api/client.go | 198 +++++++++++++++++++++++++ internal/sync/api/client_test.go | 234 ++++++++++++++++++++++++++++++ internal/sync/client.go | 140 ------------------ internal/sync/client_test.go | 127 ----------------- 12 files changed, 432 insertions(+), 663 deletions(-) delete mode 100644 cmd/sync/debug.go delete mode 100644 cmd/sync/prompt.go delete mode 100644 cmd/sync/prompt_test.go delete mode 100644 cmd/sync/sync.go create mode 100644 internal/sync/api/client.go create mode 100644 internal/sync/api/client_test.go delete mode 100644 internal/sync/client.go delete mode 100644 internal/sync/client_test.go diff --git a/cmd/root.go b/cmd/root.go index ad33ea59..a8f2110e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,7 +28,6 @@ import ( signupcmd "github.com/launchdarkly/ldcli/cmd/signup" sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps" symbolscmd "github.com/launchdarkly/ldcli/cmd/symbols" - synccmd "github.com/launchdarkly/ldcli/cmd/sync" whoamicmd "github.com/launchdarkly/ldcli/cmd/whoami" "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/config" @@ -300,7 +299,6 @@ func NewRootCommand( cmd.AddCommand(devcmd.NewDevServerCmd(clients.ResourcesClient, analyticsTrackerFn, clients.DevClient)) cmd.AddCommand(sourcemapscmd.NewSourcemapsCmd(clients.ResourcesClient, analyticsTrackerFn)) cmd.AddCommand(symbolscmd.NewSymbolsCmd(clients.ResourcesClient, analyticsTrackerFn)) - cmd.AddCommand(synccmd.NewSyncCmd(clients.ResourcesClient, analyticsTrackerFn)) cmd.AddCommand(whoamicmd.NewWhoAmICmd(clients.ResourcesClient)) resourcecmd.AddAllResourceCmds(cmd, clients.ResourcesClient, analyticsTrackerFn) diff --git a/cmd/root_test.go b/cmd/root_test.go index d8132e2d..2b34ee65 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -367,7 +367,6 @@ func TestNewRootCommand_RegistersTopLevelCommands(t *testing.T) { "signup", "sourcemaps", "symbols", - "sync", "whoami", } { assert.True(t, registered[name], "%s is not registered on the root command", name) diff --git a/cmd/sync/debug.go b/cmd/sync/debug.go deleted file mode 100644 index 82abd3f8..00000000 --- a/cmd/sync/debug.go +++ /dev/null @@ -1,41 +0,0 @@ -package sync - -import ( - "fmt" - "io" - "net/url" - - "github.com/launchdarkly/ldcli/internal/resources" -) - -type debugClient struct { - next resources.Client - out io.Writer -} - -func (c debugClient) MakeRequest( - accessToken string, - method string, - endpoint string, - contentType string, - query url.Values, - body []byte, - isBeta bool, -) ([]byte, error) { - writeRequestDebug(c.out, method, endpoint, body) - - return c.next.MakeRequest(accessToken, method, endpoint, contentType, query, body, isBeta) -} - -func (c debugClient) MakeUnauthenticatedRequest(method, endpoint string, body []byte) ([]byte, error) { - return c.next.MakeUnauthenticatedRequest(method, endpoint, body) -} - -func writeRequestDebug(out io.Writer, method, endpoint string, body []byte) { - path := endpoint - if parsed, err := url.Parse(endpoint); err == nil { - path = parsed.RequestURI() - } - - fmt.Fprintf(out, "HTTP request\nMethod: %s\nPath: %s\nBody:\n%s\n", method, path, body) -} diff --git a/cmd/sync/prompt.go b/cmd/sync/prompt.go deleted file mode 100644 index dff30129..00000000 --- a/cmd/sync/prompt.go +++ /dev/null @@ -1,75 +0,0 @@ -package sync - -import ( - "fmt" - "os" - - "github.com/spf13/cobra" - "github.com/spf13/viper" - - "github.com/launchdarkly/ldcli/cmd/cliflags" - resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" - "github.com/launchdarkly/ldcli/cmd/validators" - "github.com/launchdarkly/ldcli/internal/output" - "github.com/launchdarkly/ldcli/internal/resources" - syncapi "github.com/launchdarkly/ldcli/internal/sync" -) - -const debugFlag = "debug" - -func NewPromptCmd(client resources.Client) *cobra.Command { - cmd := &cobra.Command{ - Use: "prompt", - Short: "Check synchronization status for local prompts", - Long: "Read local prompt resources and check their synchronization status with LaunchDarkly.", - Args: func(cmd *cobra.Command, args []string) error { - if err := cobra.NoArgs(cmd, args); err != nil { - return err - } - return validators.Validate()(cmd, args) - }, - RunE: runPrompt(client), - } - - cmd.Flags().Bool(debugFlag, false, "Print the status request method, path, and body") - cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) - - return cmd -} - -func runPrompt(client resources.Client) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, _ []string) error { - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("get working directory: %w", err) - } - - repo, err := syncapi.IdentifyRepo(cwd) - if err != nil { - return err - } - - localResources, err := syncapi.Compile(os.DirFS(repo.Root)) - if err != nil { - return err - } - - requestClient := client - debug, _ := cmd.Flags().GetBool(debugFlag) - if debug { - requestClient = debugClient{next: client, out: cmd.ErrOrStderr()} - } - - _, err = syncapi.NewAPIClient(requestClient).Status( - viper.GetString(cliflags.AccessTokenFlag), - viper.GetString(cliflags.BaseURIFlag), - repo.Identifier, - localResources, - ) - if err != nil { - return output.NewCmdOutputError(err, cliflags.GetOutputKind(cmd)) - } - - return nil - } -} diff --git a/cmd/sync/prompt_test.go b/cmd/sync/prompt_test.go deleted file mode 100644 index 9e68cd6e..00000000 --- a/cmd/sync/prompt_test.go +++ /dev/null @@ -1,238 +0,0 @@ -package sync_test - -import ( - "encoding/json" - "net/url" - "os" - "os/exec" - "path/filepath" - "strconv" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/launchdarkly/ldcli/cmd" - "github.com/launchdarkly/ldcli/internal/analytics" - "github.com/launchdarkly/ldcli/internal/resources" -) - -type recordedRequest struct { - Method string - Path string - ContentType string - Body []byte -} - -type recordingClient struct { - Requests []recordedRequest - Responses [][]byte -} - -var _ resources.Client = &recordingClient{} - -func (c *recordingClient) MakeRequest( - _ string, - method string, - path string, - contentType string, - _ url.Values, - body []byte, - _ bool, -) ([]byte, error) { - c.Requests = append(c.Requests, recordedRequest{ - Method: method, - Path: path, - ContentType: contentType, - Body: append([]byte(nil), body...), - }) - - response := c.Responses[len(c.Requests)-1] - return response, nil -} - -func (*recordingClient) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { - return nil, nil -} - -func TestPromptStatus(t *testing.T) { - repo := initRepo(t) - writePrompt(t, repo, "proj", "config", "hello", true) - t.Chdir(repo) - - client := &recordingClient{ - Responses: [][]byte{[]byte(`[ - { - "resourceKind": "variation", - "lookupKey": "config/hello", - "status": "local_changed", - "syncDirection": "code_canonical", - "serverFingerprint": "sha256.server", - "error": null - } - ]`)}, - } - - stdout, stderr, err := cmd.CallCmdCapturingStderr( - t, - cmd.APIClients{ResourcesClient: client}, - analytics.NoopClientFn{}.Tracker(), - []string{ - "sync", "prompt", - "--access-token", "token", - "--base-uri", "https://example.com", - "--debug", - }, - ) - require.NoError(t, err) - require.Len(t, client.Requests, 1) - - request := client.Requests[0] - assert.Equal(t, "POST", request.Method) - assert.Equal(t, "https://example.com/api/v2/projects/proj/ai-configs/sync/status", request.Path) - assert.Equal(t, "application/json", request.ContentType) - - var body struct { - RepoIdentifier string `json:"repoIdentifier"` - Resources []struct { - Fingerprint string `json:"fingerprint"` - ResourceKind string `json:"resourceKind"` - LookupKey string `json:"lookupKey"` - Upsert bool `json:"upsert"` - } `json:"resources"` - } - require.NoError(t, json.Unmarshal(request.Body, &body)) - assert.Equal(t, "launchdarkly/ldcli", body.RepoIdentifier) - require.Len(t, body.Resources, 1) - assert.Regexp(t, `^sha256\.[0-9a-f]{64}$`, body.Resources[0].Fingerprint) - assert.Equal(t, "variation", body.Resources[0].ResourceKind) - assert.Equal(t, "config/hello", body.Resources[0].LookupKey) - assert.True(t, body.Resources[0].Upsert) - assert.NotContains(t, string(request.Body), "createIfMissing") - - assert.Empty(t, stdout) - - assert.Contains(t, string(stderr), "Method: POST") - assert.Contains(t, string(stderr), "Path: /api/v2/projects/proj/ai-configs/sync/status") - assert.Contains(t, string(stderr), `"repoIdentifier": "launchdarkly/ldcli"`) - assert.NotContains(t, string(stderr), "token") -} - -func TestPromptStatus_DoesNotPrintStatuses(t *testing.T) { - repo := initRepo(t) - writePrompt(t, repo, "proj", "config", "hello", false) - t.Chdir(repo) - - client := &recordingClient{ - Responses: [][]byte{[]byte(`[ - { - "resourceKind": "variation", - "lookupKey": "config/hello", - "status": "in_sync", - "syncDirection": "code_canonical" - } - ]`)}, - } - - stdout, stderr, err := cmd.CallCmdCapturingStderr( - t, - cmd.APIClients{ResourcesClient: client}, - analytics.NoopClientFn{}.Tracker(), - []string{ - "sync", "prompt", - "--access-token", "token", - "--base-uri", "https://example.com", - "--output", "json", - }, - ) - require.NoError(t, err) - assert.Empty(t, stdout) - assert.Empty(t, stderr) -} - -func TestPromptStatus_GroupsRequestsByProject(t *testing.T) { - repo := initRepo(t) - writePrompt(t, repo, "alpha", "config", "first", false) - writePrompt(t, repo, "zeta", "config", "second", false) - t.Chdir(repo) - - client := &recordingClient{ - Responses: [][]byte{ - []byte(`[{"resourceKind":"variation","lookupKey":"config/first","status":"in_sync","syncDirection":"code_canonical"}]`), - []byte(`[{"resourceKind":"variation","lookupKey":"config/second","status":"in_sync","syncDirection":"code_canonical"}]`), - }, - } - - _, _, err := cmd.CallCmdCapturingStderr( - t, - cmd.APIClients{ResourcesClient: client}, - analytics.NoopClientFn{}.Tracker(), - []string{ - "sync", "prompt", - "--access-token", "token", - "--base-uri", "https://example.com", - }, - ) - require.NoError(t, err) - require.Len(t, client.Requests, 2) - assert.Contains(t, client.Requests[0].Path, "/projects/alpha/") - assert.Contains(t, client.Requests[1].Path, "/projects/zeta/") -} - -func TestPromptStatus_NoResources(t *testing.T) { - repo := initRepo(t) - require.NoError(t, os.MkdirAll(filepath.Join(repo, ".launchdarkly", "proj"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(repo, ".launchdarkly", "proj", ".keep"), nil, 0o644)) - t.Chdir(repo) - - client := &recordingClient{} - stdout, _, err := cmd.CallCmdCapturingStderr( - t, - cmd.APIClients{ResourcesClient: client}, - analytics.NoopClientFn{}.Tracker(), - []string{ - "sync", "prompt", - "--access-token", "token", - "--output", "json", - }, - ) - require.NoError(t, err) - assert.Empty(t, stdout) - assert.Empty(t, client.Requests) -} - -func initRepo(t *testing.T) string { - t.Helper() - - root := t.TempDir() - runGit(t, root, "init", "--quiet") - runGit(t, root, "remote", "add", "origin", "git@github.com:launchdarkly/ldcli.git") - - return root -} - -func writePrompt(t *testing.T, root, project, config, key string, upsert bool) { - t.Helper() - - dir := filepath.Join(root, ".launchdarkly", project, "configs", config) - require.NoError(t, os.MkdirAll(dir, 0o755)) - - contents := []byte(`--- -formatVersion: 1 -upsert: ` + strconv.FormatBool(upsert) + ` -key: ` + key + ` -name: Test prompt ---- -Say hello. -`) - require.NoError(t, os.WriteFile(filepath.Join(dir, key+".prompt"), contents, 0o644)) -} - -func runGit(t *testing.T, dir string, args ...string) { - t.Helper() - - command := exec.Command("git", args...) - command.Dir = dir - output, err := command.CombinedOutput() - require.NoError(t, err, string(output)) -} diff --git a/cmd/sync/sync.go b/cmd/sync/sync.go deleted file mode 100644 index 6e338d2a..00000000 --- a/cmd/sync/sync.go +++ /dev/null @@ -1,37 +0,0 @@ -package sync - -import ( - "github.com/spf13/cobra" - "github.com/spf13/viper" - - cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics" - "github.com/launchdarkly/ldcli/cmd/cliflags" - resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" - "github.com/launchdarkly/ldcli/internal/analytics" - "github.com/launchdarkly/ldcli/internal/resources" -) - -func NewSyncCmd(client resources.Client, analyticsTrackerFn analytics.TrackerFn) *cobra.Command { - cmd := &cobra.Command{ - Use: "sync", - Short: "Synchronize local resources with LaunchDarkly", - Args: cobra.MinimumNArgs(1), - PersistentPreRun: func(cmd *cobra.Command, _ []string) { - tracker := analyticsTrackerFn( - viper.GetString(cliflags.AccessTokenFlag), - viper.GetString(cliflags.BaseURIFlag), - viper.GetBool(cliflags.AnalyticsOptOut), - ) - tracker.SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties( - cmd, - "sync", - map[string]interface{}{"action": cmd.Name()}, - )) - }, - } - - cmd.AddCommand(NewPromptCmd(client)) - cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) - - return cmd -} diff --git a/cmd/templates.go b/cmd/templates.go index d21a3186..46a5c1f2 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -19,7 +19,6 @@ Commands: {{rpad "login" 29}} Log in to your LaunchDarkly account {{rpad "signup" 29}} Create a new LaunchDarkly account {{rpad "dev-server" 29}} Run a development server to serve flags locally - {{rpad "sync" 29}} Synchronize local resources with LaunchDarkly Common resource commands: {{rpad "flags" 29}} List, create, and modify feature flags and their targeting diff --git a/cmd/templates_test.go b/cmd/templates_test.go index 7bf16f9a..a4717eb3 100644 --- a/cmd/templates_test.go +++ b/cmd/templates_test.go @@ -19,7 +19,6 @@ func TestGetUsageTemplate_ListsTopLevelCommands(t *testing.T) { "login", "signup", "dev-server", - "sync", "flags", "environments", "projects", diff --git a/internal/sync/api/client.go b/internal/sync/api/client.go new file mode 100644 index 00000000..805660a4 --- /dev/null +++ b/internal/sync/api/client.go @@ -0,0 +1,198 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +type ResourceStatus string + +const ( + ResourceStatusInSync ResourceStatus = "in_sync" + ResourceStatusLocalChanged ResourceStatus = "local_changed" + ResourceStatusServerChanged ResourceStatus = "server_changed" + ResourceStatusConflict ResourceStatus = "conflict" +) + +type SyncDirection string + +const ( + SyncDirectionCodeCanonical SyncDirection = "code_canonical" + SyncDirectionServerCanonical SyncDirection = "server_canonical" + SyncDirectionBoth SyncDirection = "both" +) + +type ResourceAction string + +const ( + ResourceActionNoChange ResourceAction = "no_change" + ResourceActionCreate ResourceAction = "create" + ResourceActionUpdate ResourceAction = "update" + ResourceActionPull ResourceAction = "pull" + ResourceActionBlocked ResourceAction = "blocked" + ResourceActionResolveConflict ResourceAction = "resolve_conflict" +) + +type ResourceError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type PlannedResource struct { + ResourceKind syncdomain.Kind `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Status ResourceStatus `json:"status"` + SyncDirection SyncDirection `json:"syncDirection"` + Action ResourceAction `json:"action"` + Diff json.RawMessage `json:"diff,omitempty"` + Error *ResourceError `json:"error,omitempty"` +} + +type ProjectPlan struct { + ProjectKey string `json:"-"` + PlanID string `json:"planId,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` + Resources []PlannedResource `json:"resources"` +} + +type planRequest struct { + Source sourceRequest `json:"source"` + DryRun bool `json:"dryRun"` + Resources []resourceInput `json:"resources"` +} + +type sourceRequest struct { + Type syncdomain.SourceType `json:"type"` + Identifier string `json:"identifier"` +} + +type resourceInput struct { + ResourceKind syncdomain.Kind `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Upsert bool `json:"upsert"` + Payload json.RawMessage `json:"payload"` +} + +type projectResources struct { + ProjectKey string + Resources []syncdomain.SyncedResource +} + +type Client struct { + transport resources.Client +} + +func NewClient(transport resources.Client) Client { + return Client{transport: transport} +} + +func (client Client) Plan( + accessToken string, + baseURI string, + source syncdomain.Source, + dryRun bool, + synced []syncdomain.SyncedResource, +) ([]ProjectPlan, error) { + plans := make([]ProjectPlan, 0) + + for _, project := range groupResourcesByProject(synced) { + plan, err := client.planProject(accessToken, baseURI, source, dryRun, project) + if err != nil { + return nil, err + } + + plans = append(plans, plan) + } + + return plans, nil +} + +func (client Client) planProject( + accessToken string, + baseURI string, + source syncdomain.Source, + dryRun bool, + project projectResources, +) (ProjectPlan, error) { + request := planRequest{ + Source: sourceRequest{ + Type: source.Type(), + Identifier: source.Identifier(), + }, + DryRun: dryRun, + Resources: make([]resourceInput, 0, len(project.Resources)), + } + + for _, resource := range project.Resources { + if resource.Kind != syncdomain.KindVariation { + return ProjectPlan{}, fmt.Errorf("unsupported sync resource kind %q", resource.Kind) + } + + request.Resources = append(request.Resources, resourceInput{ + ResourceKind: resource.Kind, + LookupKey: resource.LookupKey, + Upsert: resource.Upsert, + Payload: resource.Payload, + }) + } + + body, err := json.MarshalIndent(request, "", " ") + if err != nil { + return ProjectPlan{}, fmt.Errorf("marshal plan request: %w", err) + } + + endpoint, err := url.JoinPath( + baseURI, + "api/v2/projects", + project.ProjectKey, + "ai-configs/sync/plan", + ) + if err != nil { + return ProjectPlan{}, fmt.Errorf("build plan endpoint: %w", err) + } + + response, err := client.transport.MakeRequest( + accessToken, + http.MethodPost, + endpoint, + "application/json", + nil, + body, + false, + ) + if err != nil { + return ProjectPlan{}, err + } + + var plan ProjectPlan + if err := json.Unmarshal(response, &plan); err != nil { + return ProjectPlan{}, fmt.Errorf("decode plan response: %w", err) + } + + plan.ProjectKey = project.ProjectKey + + return plan, nil +} + +func groupResourcesByProject(synced []syncdomain.SyncedResource) []projectResources { + var projects []projectResources + byProject := make(map[string]int) + + for _, resource := range synced { + index, ok := byProject[resource.ProjectKey] + if !ok { + index = len(projects) + byProject[resource.ProjectKey] = index + projects = append(projects, projectResources{ProjectKey: resource.ProjectKey}) + } + + projects[index].Resources = append(projects[index].Resources, resource) + } + + return projects +} diff --git a/internal/sync/api/client_test.go b/internal/sync/api/client_test.go new file mode 100644 index 00000000..a63f632b --- /dev/null +++ b/internal/sync/api/client_test.go @@ -0,0 +1,234 @@ +package api + +import ( + "encoding/json" + "errors" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +type recordedRequest struct { + AccessToken string + Method string + Path string + ContentType string + Body []byte + IsBeta bool +} + +type recordingClient struct { + Requests []recordedRequest + Responses [][]byte + Err error +} + +var _ resources.Client = &recordingClient{} + +func (client *recordingClient) MakeRequest( + accessToken string, + method string, + path string, + contentType string, + _ url.Values, + body []byte, + isBeta bool, +) ([]byte, error) { + client.Requests = append(client.Requests, recordedRequest{ + AccessToken: accessToken, + Method: method, + Path: path, + ContentType: contentType, + Body: append([]byte(nil), body...), + IsBeta: isBeta, + }) + if client.Err != nil { + return nil, client.Err + } + + return client.Responses[len(client.Requests)-1], nil +} + +func (*recordingClient) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { + return nil, nil +} + +func TestClientPlan(t *testing.T) { + transport := &recordingClient{ + Responses: [][]byte{ + []byte(`{ + "resources": [{ + "resourceKind": "variation", + "lookupKey": "config/first", + "status": "local_changed", + "syncDirection": "code_canonical", + "action": "update", + "diff": {"name": {"before": "Old", "after": "First"}} + }] + }`), + []byte(`{ + "resources": [{ + "resourceKind": "variation", + "lookupKey": "config/second", + "status": "server_changed", + "syncDirection": "server_canonical", + "action": "pull" + }] + }`), + }, + } + client := NewClient(transport) + source := requireSource(t, syncdomain.SourceTypeGit, "github.com/launchdarkly/example") + + plans, err := client.Plan( + "token", + "https://example.com", + source, + true, + []syncdomain.SyncedResource{ + variationResource("alpha", "config/first", "First", true), + variationResource("zeta", "config/second", "Second", false), + }, + ) + + require.NoError(t, err) + require.Len(t, transport.Requests, 2) + require.Len(t, plans, 2) + + firstRequest := transport.Requests[0] + assert.Equal(t, "POST", firstRequest.Method) + assert.Equal(t, "token", firstRequest.AccessToken) + assert.Equal(t, "application/json", firstRequest.ContentType) + assert.False(t, firstRequest.IsBeta) + assert.Equal( + t, + "https://example.com/api/v2/projects/alpha/ai-configs/sync/plan", + firstRequest.Path, + ) + + var request planRequest + require.NoError(t, json.Unmarshal(firstRequest.Body, &request)) + assert.Equal(t, syncdomain.SourceTypeGit, request.Source.Type) + assert.Equal(t, "github.com/launchdarkly/example", request.Source.Identifier) + assert.True(t, request.DryRun) + require.Len(t, request.Resources, 1) + assert.Equal(t, syncdomain.KindVariation, request.Resources[0].ResourceKind) + assert.Equal(t, "config/first", request.Resources[0].LookupKey) + assert.True(t, request.Resources[0].Upsert) + assert.JSONEq(t, `{"key":"first","name":"First"}`, string(request.Resources[0].Payload)) + assert.NotContains(t, string(firstRequest.Body), "fingerprint") + assert.NotContains(t, string(firstRequest.Body), "repoIdentifier") + + assert.Equal(t, "alpha", plans[0].ProjectKey) + require.Len(t, plans[0].Resources, 1) + assert.Equal(t, ResourceStatusLocalChanged, plans[0].Resources[0].Status) + assert.Equal(t, ResourceActionUpdate, plans[0].Resources[0].Action) + assert.Equal(t, "zeta", plans[1].ProjectKey) + assert.Equal(t, ResourceStatusServerChanged, plans[1].Resources[0].Status) +} + +func TestClientPlanReturnsEmptyResultWithoutResources(t *testing.T) { + transport := &recordingClient{} + client := NewClient(transport) + + plans, err := client.Plan( + "token", + "https://example.com", + requireSource(t, syncdomain.SourceTypeLocal, "sha256.local"), + true, + nil, + ) + + require.NoError(t, err) + assert.Empty(t, plans) + assert.Empty(t, transport.Requests) +} + +func TestClientPlanRejectsUnsupportedResource(t *testing.T) { + transport := &recordingClient{} + client := NewClient(transport) + + _, err := client.Plan( + "token", + "https://example.com", + requireSource(t, syncdomain.SourceTypeGit, "github.com/acme/repo"), + true, + []syncdomain.SyncedResource{{ + ProjectKey: "project", + Kind: "tool", + LookupKey: "tool", + }}, + ) + + require.ErrorContains(t, err, `unsupported sync resource kind "tool"`) + assert.Empty(t, transport.Requests) +} + +func TestClientPlanReturnsTransportError(t *testing.T) { + client := NewClient(&recordingClient{Err: errors.New("unavailable")}) + + _, err := client.Plan( + "token", + "https://example.com", + requireSource(t, syncdomain.SourceTypeGit, "github.com/acme/repo"), + true, + []syncdomain.SyncedResource{variationResource("project", "config/key", "Name", false)}, + ) + + require.ErrorContains(t, err, "unavailable") +} + +func TestClientPlanRejectsInvalidResponse(t *testing.T) { + client := NewClient(&recordingClient{Responses: [][]byte{[]byte(`not json`)}}) + + _, err := client.Plan( + "token", + "https://example.com", + requireSource(t, syncdomain.SourceTypeGit, "github.com/acme/repo"), + true, + []syncdomain.SyncedResource{variationResource("project", "config/key", "Name", false)}, + ) + + require.ErrorContains(t, err, "decode plan response") +} + +func variationResource( + projectKey string, + lookupKey string, + name string, + upsert bool, +) syncdomain.SyncedResource { + payload, err := json.Marshal(map[string]string{ + "key": lookupKey[len("config/"):], + "name": name, + }) + if err != nil { + panic(err) + } + + return syncdomain.SyncedResource{ + ProjectKey: projectKey, + Kind: syncdomain.KindVariation, + LookupKey: lookupKey, + Upsert: upsert, + Payload: payload, + } +} + +func requireSource( + t *testing.T, + sourceType syncdomain.SourceType, + identifier string, +) syncdomain.Source { + t.Helper() + + source, err := syncdomain.NewSource(sourceType, identifier) + require.NoError(t, err) + + return source +} diff --git a/internal/sync/client.go b/internal/sync/client.go deleted file mode 100644 index 1af256fd..00000000 --- a/internal/sync/client.go +++ /dev/null @@ -1,140 +0,0 @@ -package sync - -import ( - "encoding/json" - "fmt" - "net/http" - "net/url" - - "github.com/launchdarkly/ldcli/internal/resources" -) - -type APIClient struct { - client resources.Client -} - -type ResourceStatus struct { - ProjectKey string `json:"projectKey"` - ResourceKind Kind `json:"resourceKind"` - LookupKey string `json:"lookupKey"` - Status string `json:"status"` - SyncDirection string `json:"syncDirection"` - ServerFingerprint Fingerprint `json:"serverFingerprint,omitempty"` - Error any `json:"error,omitempty"` -} - -type statusRequest struct { - RepoIdentifier string `json:"repoIdentifier"` - Resources []statusRequestResource `json:"resources"` -} - -type statusRequestResource struct { - Fingerprint Fingerprint `json:"fingerprint"` - ResourceKind Kind `json:"resourceKind"` - LookupKey string `json:"lookupKey"` - Upsert bool `json:"upsert"` -} - -type projectResources struct { - ProjectKey string - Resources []SyncedResource -} - -func NewAPIClient(client resources.Client) APIClient { - return APIClient{client: client} -} - -func (c APIClient) Status( - accessToken string, - baseURI string, - repoIdentifier string, - synced []SyncedResource, -) ([]ResourceStatus, error) { - statuses := make([]ResourceStatus, 0, len(synced)) - - for _, project := range groupResourcesByProject(synced) { - projectStatuses, err := c.projectStatus(accessToken, baseURI, repoIdentifier, project) - if err != nil { - return nil, err - } - statuses = append(statuses, projectStatuses...) - } - - return statuses, nil -} - -func (c APIClient) projectStatus( - accessToken string, - baseURI string, - repoIdentifier string, - project projectResources, -) ([]ResourceStatus, error) { - request := statusRequest{ - RepoIdentifier: repoIdentifier, - Resources: make([]statusRequestResource, 0, len(project.Resources)), - } - for _, resource := range project.Resources { - request.Resources = append(request.Resources, statusRequestResource{ - Fingerprint: resource.Fingerprint, - ResourceKind: resource.Kind, - LookupKey: resource.LookupKey, - Upsert: resource.Upsert, - }) - } - - body, err := json.MarshalIndent(request, "", " ") - if err != nil { - return nil, fmt.Errorf("marshal status request: %w", err) - } - - endpoint, err := url.JoinPath( - baseURI, - "api/v2/projects", - project.ProjectKey, - "ai-configs/sync/status", - ) - if err != nil { - return nil, fmt.Errorf("build status endpoint: %w", err) - } - - response, err := c.client.MakeRequest( - accessToken, - http.MethodPost, - endpoint, - "application/json", - nil, - body, - false, - ) - if err != nil { - return nil, err - } - - var statuses []ResourceStatus - if err := json.Unmarshal(response, &statuses); err != nil { - return nil, fmt.Errorf("decode status response: %w", err) - } - - for i := range statuses { - statuses[i].ProjectKey = project.ProjectKey - } - - return statuses, nil -} - -func groupResourcesByProject(synced []SyncedResource) []projectResources { - var projects []projectResources - byProject := make(map[string]int) - - for _, resource := range synced { - index, ok := byProject[resource.ProjectKey] - if !ok { - index = len(projects) - byProject[resource.ProjectKey] = index - projects = append(projects, projectResources{ProjectKey: resource.ProjectKey}) - } - projects[index].Resources = append(projects[index].Resources, resource) - } - - return projects -} diff --git a/internal/sync/client_test.go b/internal/sync/client_test.go deleted file mode 100644 index 8a44c32d..00000000 --- a/internal/sync/client_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package sync - -import ( - "encoding/json" - "errors" - "net/url" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/launchdarkly/ldcli/internal/resources" -) - -type apiRequest struct { - AccessToken string - Method string - Path string - Body []byte -} - -type apiClientStub struct { - Requests []apiRequest - Responses [][]byte - Err error -} - -var _ resources.Client = &apiClientStub{} - -func (c *apiClientStub) MakeRequest( - accessToken string, - method string, - path string, - _ string, - _ url.Values, - body []byte, - _ bool, -) ([]byte, error) { - c.Requests = append(c.Requests, apiRequest{ - AccessToken: accessToken, - Method: method, - Path: path, - Body: append([]byte(nil), body...), - }) - if c.Err != nil { - return nil, c.Err - } - - return c.Responses[len(c.Requests)-1], nil -} - -func (*apiClientStub) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { - return nil, nil -} - -func TestAPIClient_Status(t *testing.T) { - transport := &apiClientStub{ - Responses: [][]byte{ - []byte(`[{"resourceKind":"variation","lookupKey":"config/first","status":"local_changed","syncDirection":"code_canonical"}]`), - []byte(`[{"resourceKind":"tool","lookupKey":"search/2","status":"in_sync","syncDirection":"both"}]`), - }, - } - client := NewAPIClient(transport) - - statuses, err := client.Status( - "token", - "https://example.com", - "launchdarkly/ldcli", - []SyncedResource{ - { - ProjectKey: "alpha", - Kind: KindVariation, - LookupKey: "config/first", - Fingerprint: "sha256.first", - Upsert: true, - }, - { - ProjectKey: "zeta", - Kind: KindTool, - LookupKey: "search/2", - Fingerprint: "sha256.second", - }, - }, - ) - require.NoError(t, err) - require.Len(t, transport.Requests, 2) - require.Len(t, statuses, 2) - - assert.Equal(t, "POST", transport.Requests[0].Method) - assert.Equal(t, "token", transport.Requests[0].AccessToken) - assert.Equal(t, "https://example.com/api/v2/projects/alpha/ai-configs/sync/status", transport.Requests[0].Path) - assert.Equal(t, "alpha", statuses[0].ProjectKey) - assert.Equal(t, "zeta", statuses[1].ProjectKey) - - var request statusRequest - require.NoError(t, json.Unmarshal(transport.Requests[0].Body, &request)) - assert.Equal(t, "launchdarkly/ldcli", request.RepoIdentifier) - require.Len(t, request.Resources, 1) - assert.Equal(t, KindVariation, request.Resources[0].ResourceKind) - assert.Equal(t, "config/first", request.Resources[0].LookupKey) - assert.Equal(t, Fingerprint("sha256.first"), request.Resources[0].Fingerprint) - assert.True(t, request.Resources[0].Upsert) -} - -func TestAPIClient_StatusTransportError(t *testing.T) { - client := NewAPIClient(&apiClientStub{Err: errors.New("unavailable")}) - - _, err := client.Status( - "token", - "https://example.com", - "launchdarkly/ldcli", - []SyncedResource{{ProjectKey: "proj"}}, - ) - require.ErrorContains(t, err, "unavailable") -} - -func TestAPIClient_StatusInvalidResponse(t *testing.T) { - client := NewAPIClient(&apiClientStub{Responses: [][]byte{[]byte(`not json`)}}) - - _, err := client.Status( - "token", - "https://example.com", - "launchdarkly/ldcli", - []SyncedResource{{ProjectKey: "proj"}}, - ) - require.ErrorContains(t, err, "decode status response") -} From fa8a4d6926a578311acb089d8924735aa3bc6313 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Fri, 11 Sep 2026 19:14:37 -0400 Subject: [PATCH 3/5] feat(sync): preview prompt variation plans --- cmd/root.go | 2 + cmd/root_test.go | 1 + cmd/sync/output.go | 113 ++++++++++++++++ cmd/sync/prompt.go | 75 +++++++++++ cmd/sync/prompt_test.go | 290 ++++++++++++++++++++++++++++++++++++++++ cmd/sync/sync.go | 40 ++++++ cmd/templates.go | 1 + cmd/templates_test.go | 1 + 8 files changed, 523 insertions(+) create mode 100644 cmd/sync/output.go create mode 100644 cmd/sync/prompt.go create mode 100644 cmd/sync/prompt_test.go create mode 100644 cmd/sync/sync.go diff --git a/cmd/root.go b/cmd/root.go index a8f2110e..ad33ea59 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,6 +28,7 @@ import ( signupcmd "github.com/launchdarkly/ldcli/cmd/signup" sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps" symbolscmd "github.com/launchdarkly/ldcli/cmd/symbols" + synccmd "github.com/launchdarkly/ldcli/cmd/sync" whoamicmd "github.com/launchdarkly/ldcli/cmd/whoami" "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/config" @@ -299,6 +300,7 @@ func NewRootCommand( cmd.AddCommand(devcmd.NewDevServerCmd(clients.ResourcesClient, analyticsTrackerFn, clients.DevClient)) cmd.AddCommand(sourcemapscmd.NewSourcemapsCmd(clients.ResourcesClient, analyticsTrackerFn)) cmd.AddCommand(symbolscmd.NewSymbolsCmd(clients.ResourcesClient, analyticsTrackerFn)) + cmd.AddCommand(synccmd.NewSyncCmd(clients.ResourcesClient, analyticsTrackerFn)) cmd.AddCommand(whoamicmd.NewWhoAmICmd(clients.ResourcesClient)) resourcecmd.AddAllResourceCmds(cmd, clients.ResourcesClient, analyticsTrackerFn) diff --git a/cmd/root_test.go b/cmd/root_test.go index 2b34ee65..d8132e2d 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -367,6 +367,7 @@ func TestNewRootCommand_RegistersTopLevelCommands(t *testing.T) { "signup", "sourcemaps", "symbols", + "sync", "whoami", } { assert.True(t, registered[name], "%s is not registered on the root command", name) diff --git a/cmd/sync/output.go b/cmd/sync/output.go new file mode 100644 index 00000000..dcbbd340 --- /dev/null +++ b/cmd/sync/output.go @@ -0,0 +1,113 @@ +package sync + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/launchdarkly/ldcli/internal/output" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" +) + +type planOutputResource struct { + ProjectKey string `json:"projectKey"` + ResourceKind string `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Status syncapi.ResourceStatus `json:"status"` + SyncDirection syncapi.SyncDirection `json:"syncDirection"` + Action syncapi.ResourceAction `json:"action"` + Diff json.RawMessage `json:"diff,omitempty"` + Error *syncapi.ResourceError `json:"error,omitempty"` +} + +type planOutputEnvelope struct { + Items []planOutputItem `json:"items"` +} + +type planOutputItem struct { + Key string `json:"key"` + Name string `json:"name"` +} + +func writePlanOutput( + out io.Writer, + outputKind string, + plans []syncapi.ProjectPlan, +) error { + resources := flattenPlanResources(plans) + + var outputValue any = planOutputEnvelope{Items: planOutputItems(resources)} + if outputKind == "json" { + outputValue = resources + } + + data, err := json.Marshal(outputValue) + if err != nil { + return fmt.Errorf("marshal plan output: %w", err) + } + + formatted, err := output.CmdOutput("list", outputKind, data) + if err != nil { + return err + } + if formatted == "" { + return nil + } + + if _, err := fmt.Fprintln(out, formatted); err != nil { + return fmt.Errorf("write plan output: %w", err) + } + + return nil +} + +func flattenPlanResources(plans []syncapi.ProjectPlan) []planOutputResource { + resources := make([]planOutputResource, 0) + + for _, plan := range plans { + for _, resource := range plan.Resources { + resources = append(resources, planOutputResource{ + ProjectKey: plan.ProjectKey, + ResourceKind: string(resource.ResourceKind), + LookupKey: resource.LookupKey, + Status: resource.Status, + SyncDirection: resource.SyncDirection, + Action: resource.Action, + Diff: resource.Diff, + Error: resource.Error, + }) + } + } + + return resources +} + +func planOutputItems(resources []planOutputResource) []planOutputItem { + items := make([]planOutputItem, 0, len(resources)) + + for _, resource := range resources { + details := fmt.Sprintf( + "status=%s direction=%s action=%s", + resource.Status, + resource.SyncDirection, + resource.Action, + ) + if len(resource.Diff) > 0 { + details += " diff=" + string(resource.Diff) + } + if resource.Error != nil { + details += fmt.Sprintf( + " error=%s: %s", + resource.Error.Code, + resource.Error.Message, + ) + } + + items = append(items, planOutputItem{ + Key: resource.ProjectKey + "/" + resource.LookupKey, + Name: details, + }) + } + + return items +} diff --git a/cmd/sync/prompt.go b/cmd/sync/prompt.go new file mode 100644 index 00000000..2c36f30b --- /dev/null +++ b/cmd/sync/prompt.go @@ -0,0 +1,75 @@ +package sync + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/cmd/validators" + "github.com/launchdarkly/ldcli/internal/config" + "github.com/launchdarkly/ldcli/internal/output" + "github.com/launchdarkly/ldcli/internal/resources" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" + syncsource "github.com/launchdarkly/ldcli/internal/sync/source" +) + +func NewPromptCmd(client resources.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "prompt", + Short: "Preview synchronization changes for local prompts", + Long: "Read local prompt variations and preview the changes LaunchDarkly would make without creating or applying a plan.", + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.NoArgs(cmd, args); err != nil { + return err + } + + return validators.Validate()(cmd, args) + }, + RunE: runPrompt(client), + } + + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + + return cmd +} + +func runPrompt(client resources.Client) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, _ []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + + workspace, err := syncsource.NewResolver(config.GetConfigFile()).Resolve(cwd) + if err != nil { + return err + } + + localResources, err := synclocal.Compile(os.DirFS(workspace.Root)) + if err != nil { + return err + } + + plans, err := syncapi.NewClient(client).Plan( + viper.GetString(cliflags.AccessTokenFlag), + viper.GetString(cliflags.BaseURIFlag), + workspace.Source, + true, + localResources, + ) + if err != nil { + return output.NewCmdOutputError(err, cliflags.GetOutputKind(cmd)) + } + + return writePlanOutput( + cmd.OutOrStdout(), + cliflags.GetOutputKind(cmd), + plans, + ) + } +} diff --git a/cmd/sync/prompt_test.go b/cmd/sync/prompt_test.go new file mode 100644 index 00000000..eec34a61 --- /dev/null +++ b/cmd/sync/prompt_test.go @@ -0,0 +1,290 @@ +package sync_test + +import ( + "encoding/json" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/cmd" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/resources" +) + +type recordedRequest struct { + Method string + Path string + ContentType string + Body []byte +} + +type recordingClient struct { + Requests []recordedRequest + Responses [][]byte +} + +var _ resources.Client = &recordingClient{} + +func (client *recordingClient) MakeRequest( + _ string, + method string, + path string, + contentType string, + _ url.Values, + body []byte, + _ bool, +) ([]byte, error) { + client.Requests = append(client.Requests, recordedRequest{ + Method: method, + Path: path, + ContentType: contentType, + Body: append([]byte(nil), body...), + }) + + return client.Responses[len(client.Requests)-1], nil +} + +func (*recordingClient) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { + return nil, nil +} + +func TestPromptPreview(t *testing.T) { + repository := initRepository(t) + writePrompt(t, repository, "project", "support", "default", true) + t.Chdir(repository) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + client := &recordingClient{ + Responses: [][]byte{[]byte(`{ + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "local_changed", + "syncDirection": "code_canonical", + "action": "update", + "diff": {"name": {"before": "Old", "after": "Default"}} + }] + }`)}, + } + + stdout, _, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--access-token", "token", + "--base-uri", "https://example.com", + "--output", "json", + }, + ) + + require.NoError(t, err) + require.Len(t, client.Requests, 1) + + request := client.Requests[0] + assert.Equal(t, "POST", request.Method) + assert.Equal( + t, + "https://example.com/api/v2/projects/project/ai-configs/sync/plan", + request.Path, + ) + assert.Equal(t, "application/json", request.ContentType) + + var body struct { + Source struct { + Type string `json:"type"` + Identifier string `json:"identifier"` + } `json:"source"` + DryRun bool `json:"dryRun"` + Resources []struct { + ResourceKind string `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Upsert bool `json:"upsert"` + Payload json.RawMessage `json:"payload"` + } `json:"resources"` + } + require.NoError(t, json.Unmarshal(request.Body, &body)) + assert.Equal(t, "git", body.Source.Type) + assert.Equal(t, "github.com/launchdarkly/example", body.Source.Identifier) + assert.True(t, body.DryRun) + require.Len(t, body.Resources, 1) + assert.Equal(t, "variation", body.Resources[0].ResourceKind) + assert.Equal(t, "support/default", body.Resources[0].LookupKey) + assert.True(t, body.Resources[0].Upsert) + assert.JSONEq(t, `{ + "mode": "completion", + "key": "default", + "name": "Default", + "messages": [{"role": "system", "content": "Say hello."}] + }`, string(body.Resources[0].Payload)) + assert.NotContains(t, string(request.Body), "fingerprint") + + var output []map[string]any + require.NoError(t, json.Unmarshal([]byte(stdout), &output)) + require.Len(t, output, 1) + assert.Equal(t, "project", output[0]["projectKey"]) + assert.Equal(t, "local_changed", output[0]["status"]) + assert.Equal(t, "update", output[0]["action"]) + assert.NotNil(t, output[0]["diff"]) +} + +func TestPromptPreviewUsesLocalSourceOutsideGit(t *testing.T) { + workspace := t.TempDir() + writePrompt(t, workspace, "project", "support", "default", false) + t.Chdir(workspace) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + client := &recordingClient{ + Responses: [][]byte{[]byte(`{ + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "in_sync", + "syncDirection": "code_canonical", + "action": "no_change" + }] + }`)}, + } + + _, _, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--access-token", "token", + "--base-uri", "https://example.com", + "--output", "json", + }, + ) + + require.NoError(t, err) + require.Len(t, client.Requests, 1) + + var body struct { + Source struct { + Type string `json:"type"` + Identifier string `json:"identifier"` + } `json:"source"` + } + require.NoError(t, json.Unmarshal(client.Requests[0].Body, &body)) + assert.Equal(t, "local", body.Source.Type) + assert.Regexp(t, `^sha256\.[0-9a-f]{64}$`, body.Source.Identifier) + assert.NotContains(t, string(client.Requests[0].Body), workspace) +} + +func TestPromptPreviewGroupsRequestsByProject(t *testing.T) { + repository := initRepository(t) + writePrompt(t, repository, "alpha", "support", "first", false) + writePrompt(t, repository, "zeta", "support", "second", false) + t.Chdir(repository) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + client := &recordingClient{ + Responses: [][]byte{ + []byte(`{"resources":[{"resourceKind":"variation","lookupKey":"support/first","status":"local_changed","syncDirection":"code_canonical","action":"update","diff":{"name":{"before":"Old","after":"Default"}}}]}`), + []byte(`{"resources":[{"resourceKind":"variation","lookupKey":"support/second","status":"server_changed","syncDirection":"server_canonical","action":"pull"}]}`), + }, + } + + stdout, _, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--access-token", "token", + "--base-uri", "https://example.com", + "--output", "plaintext", + }, + ) + + require.NoError(t, err) + require.Len(t, client.Requests, 2) + assert.Contains(t, client.Requests[0].Path, "/projects/alpha/") + assert.Contains(t, client.Requests[1].Path, "/projects/zeta/") + assert.Contains(t, string(stdout), "server_changed") + assert.Contains(t, string(stdout), "pull") + assert.Contains(t, string(stdout), "diff=") +} + +func TestPromptPreviewWithoutResourcesMakesNoRequest(t *testing.T) { + repository := initRepository(t) + require.NoError(t, os.MkdirAll( + filepath.Join(repository, ".launchdarkly", "project"), + 0o755, + )) + t.Chdir(repository) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + client := &recordingClient{} + stdout, _, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--access-token", "token", + "--output", "json", + }, + ) + + require.NoError(t, err) + assert.JSONEq(t, `[]`, string(stdout)) + assert.Empty(t, client.Requests) +} + +func initRepository(t *testing.T) string { + t.Helper() + + root := t.TempDir() + runGit(t, root, "init", "--quiet") + runGit(t, root, "remote", "add", "origin", "git@github.com:launchdarkly/example.git") + + return root +} + +func writePrompt( + t *testing.T, + root string, + projectKey string, + configKey string, + variationKey string, + upsert bool, +) { + t.Helper() + + dir := filepath.Join(root, ".launchdarkly", projectKey, "configs", configKey) + require.NoError(t, os.MkdirAll(dir, 0o755)) + + contents := `--- +formatVersion: 1 +upsert: ` + strconv.FormatBool(upsert) + ` +mode: completion +key: ` + variationKey + ` +name: Default +--- +Say hello. +` + require.NoError(t, os.WriteFile( + filepath.Join(dir, variationKey+".prompt.md"), + []byte(contents), + 0o644, + )) +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + + command := exec.Command("git", args...) + command.Dir = dir + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) +} diff --git a/cmd/sync/sync.go b/cmd/sync/sync.go new file mode 100644 index 00000000..46411635 --- /dev/null +++ b/cmd/sync/sync.go @@ -0,0 +1,40 @@ +package sync + +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" + + cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics" + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/resources" +) + +func NewSyncCmd( + client resources.Client, + analyticsTrackerFn analytics.TrackerFn, +) *cobra.Command { + cmd := &cobra.Command{ + Use: "sync", + Short: "Synchronize local resources with LaunchDarkly", + Args: cobra.MinimumNArgs(1), + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + tracker := analyticsTrackerFn( + viper.GetString(cliflags.AccessTokenFlag), + viper.GetString(cliflags.BaseURIFlag), + viper.GetBool(cliflags.AnalyticsOptOut), + ) + tracker.SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties( + cmd, + "sync", + map[string]interface{}{"action": cmd.Name()}, + )) + }, + } + + cmd.AddCommand(NewPromptCmd(client)) + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + + return cmd +} diff --git a/cmd/templates.go b/cmd/templates.go index 46a5c1f2..d21a3186 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -19,6 +19,7 @@ Commands: {{rpad "login" 29}} Log in to your LaunchDarkly account {{rpad "signup" 29}} Create a new LaunchDarkly account {{rpad "dev-server" 29}} Run a development server to serve flags locally + {{rpad "sync" 29}} Synchronize local resources with LaunchDarkly Common resource commands: {{rpad "flags" 29}} List, create, and modify feature flags and their targeting diff --git a/cmd/templates_test.go b/cmd/templates_test.go index a4717eb3..7bf16f9a 100644 --- a/cmd/templates_test.go +++ b/cmd/templates_test.go @@ -19,6 +19,7 @@ func TestGetUsageTemplate_ListsTopLevelCommands(t *testing.T) { "login", "signup", "dev-server", + "sync", "flags", "environments", "projects", From b6753a8832cccfa15aa5230f0c6053fe8a5a4aa8 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Fri, 11 Sep 2026 19:40:29 -0400 Subject: [PATCH 4/5] test(sync): use unknown resource fixture --- internal/sync/api/client_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/sync/api/client_test.go b/internal/sync/api/client_test.go index a63f632b..32adec41 100644 --- a/internal/sync/api/client_test.go +++ b/internal/sync/api/client_test.go @@ -160,12 +160,12 @@ func TestClientPlanRejectsUnsupportedResource(t *testing.T) { true, []syncdomain.SyncedResource{{ ProjectKey: "project", - Kind: "tool", - LookupKey: "tool", + Kind: "unknown", + LookupKey: "unknown", }}, ) - require.ErrorContains(t, err, `unsupported sync resource kind "tool"`) + require.ErrorContains(t, err, `unsupported sync resource kind "unknown"`) assert.Empty(t, transport.Requests) } From a465c8eb14bb96b989d94eec37cfa9918d829c5b Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Fri, 11 Sep 2026 20:24:28 -0400 Subject: [PATCH 5/5] refactor(sync): use status as plan result --- cmd/sync/output.go | 5 +---- cmd/sync/prompt_test.go | 12 +++++------- internal/sync/api/client.go | 12 ------------ internal/sync/api/client_test.go | 5 +---- 4 files changed, 7 insertions(+), 27 deletions(-) diff --git a/cmd/sync/output.go b/cmd/sync/output.go index dcbbd340..591185b1 100644 --- a/cmd/sync/output.go +++ b/cmd/sync/output.go @@ -15,7 +15,6 @@ type planOutputResource struct { LookupKey string `json:"lookupKey"` Status syncapi.ResourceStatus `json:"status"` SyncDirection syncapi.SyncDirection `json:"syncDirection"` - Action syncapi.ResourceAction `json:"action"` Diff json.RawMessage `json:"diff,omitempty"` Error *syncapi.ResourceError `json:"error,omitempty"` } @@ -72,7 +71,6 @@ func flattenPlanResources(plans []syncapi.ProjectPlan) []planOutputResource { LookupKey: resource.LookupKey, Status: resource.Status, SyncDirection: resource.SyncDirection, - Action: resource.Action, Diff: resource.Diff, Error: resource.Error, }) @@ -87,10 +85,9 @@ func planOutputItems(resources []planOutputResource) []planOutputItem { for _, resource := range resources { details := fmt.Sprintf( - "status=%s direction=%s action=%s", + "status=%s direction=%s", resource.Status, resource.SyncDirection, - resource.Action, ) if len(resource.Diff) > 0 { details += " diff=" + string(resource.Diff) diff --git a/cmd/sync/prompt_test.go b/cmd/sync/prompt_test.go index eec34a61..d7d5b2eb 100644 --- a/cmd/sync/prompt_test.go +++ b/cmd/sync/prompt_test.go @@ -67,7 +67,6 @@ func TestPromptPreview(t *testing.T) { "lookupKey": "support/default", "status": "local_changed", "syncDirection": "code_canonical", - "action": "update", "diff": {"name": {"before": "Old", "after": "Default"}} }] }`)}, @@ -131,7 +130,7 @@ func TestPromptPreview(t *testing.T) { require.Len(t, output, 1) assert.Equal(t, "project", output[0]["projectKey"]) assert.Equal(t, "local_changed", output[0]["status"]) - assert.Equal(t, "update", output[0]["action"]) + assert.NotContains(t, output[0], "action") assert.NotNil(t, output[0]["diff"]) } @@ -147,8 +146,7 @@ func TestPromptPreviewUsesLocalSourceOutsideGit(t *testing.T) { "resourceKind": "variation", "lookupKey": "support/default", "status": "in_sync", - "syncDirection": "code_canonical", - "action": "no_change" + "syncDirection": "code_canonical" }] }`)}, } @@ -189,8 +187,8 @@ func TestPromptPreviewGroupsRequestsByProject(t *testing.T) { client := &recordingClient{ Responses: [][]byte{ - []byte(`{"resources":[{"resourceKind":"variation","lookupKey":"support/first","status":"local_changed","syncDirection":"code_canonical","action":"update","diff":{"name":{"before":"Old","after":"Default"}}}]}`), - []byte(`{"resources":[{"resourceKind":"variation","lookupKey":"support/second","status":"server_changed","syncDirection":"server_canonical","action":"pull"}]}`), + []byte(`{"resources":[{"resourceKind":"variation","lookupKey":"support/first","status":"local_changed","syncDirection":"code_canonical","diff":{"name":{"before":"Old","after":"Default"}}}]}`), + []byte(`{"resources":[{"resourceKind":"variation","lookupKey":"support/second","status":"server_changed","syncDirection":"server_canonical"}]}`), }, } @@ -211,7 +209,7 @@ func TestPromptPreviewGroupsRequestsByProject(t *testing.T) { assert.Contains(t, client.Requests[0].Path, "/projects/alpha/") assert.Contains(t, client.Requests[1].Path, "/projects/zeta/") assert.Contains(t, string(stdout), "server_changed") - assert.Contains(t, string(stdout), "pull") + assert.NotContains(t, string(stdout), "action=") assert.Contains(t, string(stdout), "diff=") } diff --git a/internal/sync/api/client.go b/internal/sync/api/client.go index 805660a4..38ebc15f 100644 --- a/internal/sync/api/client.go +++ b/internal/sync/api/client.go @@ -27,17 +27,6 @@ const ( SyncDirectionBoth SyncDirection = "both" ) -type ResourceAction string - -const ( - ResourceActionNoChange ResourceAction = "no_change" - ResourceActionCreate ResourceAction = "create" - ResourceActionUpdate ResourceAction = "update" - ResourceActionPull ResourceAction = "pull" - ResourceActionBlocked ResourceAction = "blocked" - ResourceActionResolveConflict ResourceAction = "resolve_conflict" -) - type ResourceError struct { Code string `json:"code"` Message string `json:"message"` @@ -48,7 +37,6 @@ type PlannedResource struct { LookupKey string `json:"lookupKey"` Status ResourceStatus `json:"status"` SyncDirection SyncDirection `json:"syncDirection"` - Action ResourceAction `json:"action"` Diff json.RawMessage `json:"diff,omitempty"` Error *ResourceError `json:"error,omitempty"` } diff --git a/internal/sync/api/client_test.go b/internal/sync/api/client_test.go index 32adec41..06ee6361 100644 --- a/internal/sync/api/client_test.go +++ b/internal/sync/api/client_test.go @@ -67,7 +67,6 @@ func TestClientPlan(t *testing.T) { "lookupKey": "config/first", "status": "local_changed", "syncDirection": "code_canonical", - "action": "update", "diff": {"name": {"before": "Old", "after": "First"}} }] }`), @@ -76,8 +75,7 @@ func TestClientPlan(t *testing.T) { "resourceKind": "variation", "lookupKey": "config/second", "status": "server_changed", - "syncDirection": "server_canonical", - "action": "pull" + "syncDirection": "server_canonical" }] }`), }, @@ -127,7 +125,6 @@ func TestClientPlan(t *testing.T) { assert.Equal(t, "alpha", plans[0].ProjectKey) require.Len(t, plans[0].Resources, 1) assert.Equal(t, ResourceStatusLocalChanged, plans[0].Resources[0].Status) - assert.Equal(t, ResourceActionUpdate, plans[0].Resources[0].Action) assert.Equal(t, "zeta", plans[1].ProjectKey) assert.Equal(t, ResourceStatusServerChanged, plans[1].Resources[0].Status) }