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",