From cc59a0d34adbd4472728767f28dd14fac8b44d5a Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Sat, 19 Sep 2026 02:09:59 -0400 Subject: [PATCH] feat(sync): apply prompt sync plans --- cmd/sync/output.go | 385 ++++++++++++++++++++++++++---- cmd/sync/output_test.go | 139 +++++++++++ cmd/sync/prompt.go | 230 +++++++++++++++++- cmd/sync/prompt_test.go | 269 ++++++++++++++++++++- internal/sync/api/client.go | 99 ++++++++ internal/sync/api/client_test.go | 82 +++++++ internal/sync/local/store.go | 17 ++ internal/sync/local/store_test.go | 23 ++ 8 files changed, 1185 insertions(+), 59 deletions(-) create mode 100644 cmd/sync/output_test.go diff --git a/cmd/sync/output.go b/cmd/sync/output.go index b9c3a252..c9be5f8d 100644 --- a/cmd/sync/output.go +++ b/cmd/sync/output.go @@ -1,11 +1,17 @@ package sync import ( + "bytes" "encoding/json" "fmt" "io" + "os" + "slices" + "strings" + + "github.com/charmbracelet/lipgloss" + "golang.org/x/term" - "github.com/launchdarkly/ldcli/internal/output" syncapi "github.com/launchdarkly/ldcli/internal/sync/api" ) @@ -25,13 +31,29 @@ type projectPlanOutput struct { Resources []planOutputResource `json:"resources"` } -type planOutputEnvelope struct { - Items []planOutputItem `json:"items"` +type applyOutputResource struct { + ResourceKind string `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Status syncapi.ResourceStatus `json:"status"` + SyncDirection syncapi.SyncDirection `json:"syncDirection"` + ApplyStatus syncapi.ResourceApplyStatus `json:"applyStatus"` + Diff json.RawMessage `json:"diff,omitempty"` + Error *syncapi.ResourceError `json:"error,omitempty"` + ApplyError *syncapi.ResourceError `json:"applyError,omitempty"` +} + +type projectApplyOutput struct { + ProjectKey string `json:"projectKey"` + PlanID string `json:"planId"` + Status syncapi.PlanStatus `json:"status"` + AppliedAt string `json:"appliedAt,omitempty"` + Error *syncapi.ResourceError `json:"error,omitempty"` + Resources []applyOutputResource `json:"resources"` } -type planOutputItem struct { - Key string `json:"key"` - Name string `json:"name"` +type syncOutput struct { + Plans []projectPlanOutput `json:"plans,omitempty"` + Applies []projectApplyOutput `json:"applies,omitempty"` } func writePlanOutput( @@ -39,31 +61,47 @@ func writePlanOutput( outputKind string, plans []syncapi.ProjectPlan, ) error { + if outputKind == "" { + outputKind = "plaintext" + } outputPlans := newProjectPlanOutputs(plans) - - var outputValue any = planOutputEnvelope{Items: planOutputItems(outputPlans)} if outputKind == "json" { - outputValue = outputPlans + return writeJSON(out, outputPlans) } - - data, err := json.Marshal(outputValue) - if err != nil { - return fmt.Errorf("marshal plan output: %w", err) + if outputKind != "plaintext" && outputKind != "markdown" { + return fmt.Errorf("unsupported output kind %q", outputKind) } + return writePlanReview(out, outputKind, plans, terminalWidth(out)) +} - formatted, err := output.CmdOutput("list", outputKind, data) - if err != nil { - return err +func writeSyncOutput( + out io.Writer, + outputKind string, + plans []syncapi.ProjectPlan, + applies []syncapi.ProjectApply, +) error { + if outputKind == "" { + outputKind = "plaintext" } - if formatted == "" { - return nil + if outputKind == "json" { + return writeJSON(out, syncOutput{ + Plans: newProjectPlanOutputs(plans), + Applies: newProjectApplyOutputs(applies), + }) } - - if _, err := fmt.Fprintln(out, formatted); err != nil { - return fmt.Errorf("write plan output: %w", err) + if outputKind != "plaintext" && outputKind != "markdown" { + return fmt.Errorf("unsupported output kind %q", outputKind) } - return nil + if len(plans) != 0 { + if err := writePlanReview(out, outputKind, plans, terminalWidth(out)); err != nil { + return err + } + if len(applies) != 0 { + _, _ = fmt.Fprintln(out) + } + } + return writeApplyResults(out, outputKind, applies) } func newProjectPlanOutputs(plans []syncapi.ProjectPlan) []projectPlanOutput { @@ -91,44 +129,295 @@ func newProjectPlanOutputs(plans []syncapi.ProjectPlan) []projectPlanOutput { return outputPlans } -func planOutputItems(plans []projectPlanOutput) []planOutputItem { - var items []planOutputItem - - for _, plan := range plans { - if plan.PlanID != "" { - items = append(items, planOutputItem{ - Key: plan.ProjectKey, - Name: fmt.Sprintf( - "planId=%s expiresAt=%s", - plan.PlanID, - plan.ExpiresAt, - ), +func newProjectApplyOutputs( + applies []syncapi.ProjectApply, +) []projectApplyOutput { + result := make([]projectApplyOutput, 0, len(applies)) + for _, apply := range applies { + output := projectApplyOutput{ + ProjectKey: apply.ProjectKey, + PlanID: apply.PlanID, + Status: apply.Status, + AppliedAt: apply.AppliedAt, + Error: apply.Error, + Resources: make([]applyOutputResource, 0, len(apply.Resources)), + } + for _, resource := range apply.Resources { + output.Resources = append(output.Resources, applyOutputResource{ + ResourceKind: string(resource.ResourceKind), + LookupKey: resource.LookupKey, + Status: resource.Status, + SyncDirection: resource.SyncDirection, + ApplyStatus: resource.ApplyStatus, + Diff: resource.Diff, + Error: resource.Error, + ApplyError: resource.ApplyError, }) } + result = append(result, output) + } + return result +} - for _, resource := range plan.Resources { - details := fmt.Sprintf( - "status=%s direction=%s", - resource.Status, - resource.SyncDirection, +func writeJSON(out io.Writer, value any) error { + encoder := json.NewEncoder(out) + encoder.SetIndent("", " ") + if err := encoder.Encode(value); err != nil { + return fmt.Errorf("write sync output: %w", err) + } + return nil +} + +func writePlanReview( + out io.Writer, + outputKind string, + plans []syncapi.ProjectPlan, + width int, +) error { + for planIndex, plan := range plans { + if planIndex != 0 { + _, _ = fmt.Fprintln(out) + } + if outputKind == "markdown" { + _, _ = fmt.Fprintf(out, "## Project `%s`\n", plan.ProjectKey) + } else { + _, _ = fmt.Fprintf(out, "Project: %s\n", plan.ProjectKey) + } + if plan.PlanID != "" { + _, _ = fmt.Fprintf( + out, + "Plan: %s Expires: %s\n", + plan.PlanID, + plan.ExpiresAt, ) - if len(resource.Diff) > 0 { - details += " diff=" + string(resource.Diff) + } + for _, resource := range plan.Resources { + if outputKind == "markdown" { + _, _ = fmt.Fprintf( + out, + "\n### `%s`\n\nStatus: `%s` Direction: `%s`\n", + resource.LookupKey, + resource.Status, + resource.SyncDirection, + ) + } else { + _, _ = fmt.Fprintf( + out, + "\n%s\n Status: %s Direction: %s\n", + resource.LookupKey, + resource.Status, + resource.SyncDirection, + ) } if resource.Error != nil { - details += fmt.Sprintf( - " error=%s: %s", + _, _ = fmt.Fprintf( + out, + " Error: %s: %s\n", resource.Error.Code, resource.Error.Message, ) } + if len(resource.Diff) != 0 { + rendered, err := renderVariationDiff( + resource.Diff, + outputKind, + width, + ) + if err != nil { + return err + } + _, _ = fmt.Fprint(out, rendered) + } + } + } + return nil +} - items = append(items, planOutputItem{ - Key: plan.ProjectKey + "/" + resource.LookupKey, - Name: details, - }) +func writeApplyResults( + out io.Writer, + outputKind string, + applies []syncapi.ProjectApply, +) error { + for index, apply := range applies { + if index != 0 { + _, _ = fmt.Fprintln(out) + } + if outputKind == "markdown" { + _, _ = fmt.Fprintf( + out, + "## Apply `%s`\n\nProject: `%s` Status: `%s`\n", + apply.PlanID, + apply.ProjectKey, + apply.Status, + ) + } else { + _, _ = fmt.Fprintf( + out, + "Apply: %s Project: %s Status: %s\n", + apply.PlanID, + apply.ProjectKey, + apply.Status, + ) + } + if apply.Error != nil { + _, _ = fmt.Fprintf( + out, + " Error: %s: %s\n", + apply.Error.Code, + apply.Error.Message, + ) } + for _, resource := range apply.Resources { + _, _ = fmt.Fprintf( + out, + " %s apply=%s\n", + resource.LookupKey, + resource.ApplyStatus, + ) + if resource.ApplyError != nil { + _, _ = fmt.Fprintf( + out, + " Error: %s: %s\n", + resource.ApplyError.Code, + resource.ApplyError.Message, + ) + } + } + } + return nil +} + +type variationFieldDiff struct { + Before json.RawMessage `json:"before"` + After json.RawMessage `json:"after"` +} + +func renderVariationDiff( + payload json.RawMessage, + outputKind string, + width int, +) (string, error) { + fields := make(map[string]variationFieldDiff) + if err := json.Unmarshal(payload, &fields); err != nil { + return "", fmt.Errorf("decode variation diff: %w", err) + } + keys := make([]string, 0, len(fields)) + for key := range fields { + keys = append(keys, key) } + slices.Sort(keys) - return items + var rendered strings.Builder + for _, key := range keys { + diff := fields[key] + before, err := formatDiffValue(diff.Before) + if err != nil { + return "", err + } + after, err := formatDiffValue(diff.After) + if err != nil { + return "", err + } + change := "changed" + if len(diff.Before) == 0 { + change = "added" + } + if len(diff.After) == 0 { + change = "removed" + } + + if outputKind == "plaintext" && width >= 100 { + rendered.WriteString(renderSideBySideDiff( + key, + change, + before, + after, + width, + )) + continue + } + + if outputKind == "markdown" { + _, _ = fmt.Fprintf(&rendered, "\n#### %s (%s)\n\n", key, change) + _, _ = fmt.Fprintf( + &rendered, + "**Before**\n\n```json\n%s\n```\n\n", + before, + ) + _, _ = fmt.Fprintf( + &rendered, + "**After**\n\n```json\n%s\n```\n", + after, + ) + continue + } + _, _ = fmt.Fprintf(&rendered, " %s (%s)\n", key, change) + _, _ = fmt.Fprintf( + &rendered, + " Before:\n%s\n", + indent(before, 6), + ) + _, _ = fmt.Fprintf( + &rendered, + " After:\n%s\n", + indent(after, 6), + ) + } + return rendered.String(), nil +} + +func renderSideBySideDiff( + field string, + change string, + before string, + after string, + width int, +) string { + gap := 2 + panelWidth := (width - gap) / 2 + panelStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Padding(0, 1). + Width(panelWidth - 2) + beforePanel := panelStyle.Render("Before\n" + before) + afterPanel := panelStyle.Render("After\n" + after) + return fmt.Sprintf( + " %s (%s)\n%s\n", + field, + change, + lipgloss.JoinHorizontal( + lipgloss.Top, + beforePanel, + strings.Repeat(" ", gap), + afterPanel, + ), + ) +} + +func formatDiffValue(value json.RawMessage) (string, error) { + if len(value) == 0 { + return "(not present)", nil + } + var formatted bytes.Buffer + if err := json.Indent(&formatted, value, "", " "); err != nil { + return "", fmt.Errorf("format variation diff: %w", err) + } + return formatted.String(), nil +} + +func indent(value string, spaces int) string { + prefix := strings.Repeat(" ", spaces) + return prefix + strings.ReplaceAll(value, "\n", "\n"+prefix) +} + +func terminalWidth(out io.Writer) int { + file, ok := out.(*os.File) + if !ok || !term.IsTerminal(int(file.Fd())) { + return 0 + } + width, _, err := term.GetSize(int(file.Fd())) + if err != nil { + return 0 + } + return width } diff --git a/cmd/sync/output_test.go b/cmd/sync/output_test.go new file mode 100644 index 00000000..44b70d39 --- /dev/null +++ b/cmd/sync/output_test.go @@ -0,0 +1,139 @@ +package sync + +import ( + "bytes" + "encoding/json" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" +) + +func TestConfirmApply(t *testing.T) { + for _, test := range []struct { + name string + input string + terminal bool + confirmed bool + wantError string + }{ + {"yes", "yes\n", true, true, ""}, + {"declined", "n\n", true, false, ""}, + {"non-terminal", "", false, false, "rerun with --yes"}, + } { + t.Run(test.name, func(t *testing.T) { + var prompt bytes.Buffer + confirmed, err := confirmApply( + strings.NewReader(test.input), + &prompt, + func(_ io.Reader, _ io.Writer) bool { + return test.terminal + }, + ) + + assert.Equal(t, test.confirmed, confirmed) + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + } else { + require.NoError(t, err) + assert.Contains(t, prompt.String(), "Apply these plans?") + } + }) + } +} + +func TestRenderVariationDiffSortsFieldsAndUsesStackedFallback(t *testing.T) { + rendered, err := renderVariationDiff( + json.RawMessage(`{ + "model": { + "before": {"parameters": {"temperature": 0.2}}, + "after": {"parameters": {"temperature": 0.3}} + }, + "instructions": {"after": "Be helpful"} + }`), + "plaintext", + 0, + ) + + require.NoError(t, err) + assert.Less(t, strings.Index(rendered, "instructions"), strings.Index(rendered, "model")) + assert.Contains(t, rendered, "instructions (added)") + assert.Contains(t, rendered, "Before:\n (not present)") + assert.Contains(t, rendered, `"temperature": 0.2`) + assert.Contains(t, rendered, `"temperature": 0.3`) +} + +func TestRenderVariationDiffUsesSideBySidePanelsForWideTerminal(t *testing.T) { + rendered, err := renderVariationDiff( + json.RawMessage(`{"name":{"before":"Old","after":"New"}}`), + "plaintext", + 120, + ) + + require.NoError(t, err) + lines := strings.Split(rendered, "\n") + require.Greater(t, len(lines), 3) + assert.Contains(t, rendered, "name (changed)") + assert.Contains(t, rendered, "Before") + assert.Contains(t, rendered, "After") + assert.Contains(t, rendered, "Old") + assert.Contains(t, rendered, "New") + assert.Contains(t, rendered, "╭") +} + +func TestRenderVariationDiffUsesMarkdownFallback(t *testing.T) { + rendered, err := renderVariationDiff( + json.RawMessage(`{"name":{"before":"Old","after":"New"}}`), + "markdown", + 120, + ) + + require.NoError(t, err) + assert.Contains(t, rendered, "#### name (changed)") + assert.Contains(t, rendered, "**Before**") + assert.Contains(t, rendered, "```json") + assert.NotContains(t, rendered, "╭") +} + +func TestWriteSyncOutputJSONIncludesPartialApplyOutcomes(t *testing.T) { + var output bytes.Buffer + err := writeSyncOutput( + &output, + "json", + []syncapi.ProjectPlan{{ + ProjectKey: "project", + PlanID: "617c83f1-cd9a-4865-8f37-bb11f88e2147", + }}, + []syncapi.ProjectApply{{ + ProjectKey: "project", + PlanID: "617c83f1-cd9a-4865-8f37-bb11f88e2147", + Status: syncapi.PlanStatusFailed, + Resources: []syncapi.AppliedResource{{ + ResourceKind: syncdomain.KindVariation, + LookupKey: "support/default", + ApplyStatus: syncapi.ResourceApplyStatusReconciliationRequired, + ApplyError: &syncapi.ResourceError{ + Code: "verification_failed", + Message: "post-write verification failed", + }, + }}, + }}, + ) + + require.NoError(t, err) + var decoded syncOutput + require.NoError(t, json.Unmarshal(output.Bytes(), &decoded)) + require.Len(t, decoded.Plans, 1) + require.Len(t, decoded.Applies, 1) + require.Len(t, decoded.Applies[0].Resources, 1) + assert.Equal( + t, + syncapi.ResourceApplyStatusReconciliationRequired, + decoded.Applies[0].Resources[0].ApplyStatus, + ) +} diff --git a/cmd/sync/prompt.go b/cmd/sync/prompt.go index 47731612..cf8a8230 100644 --- a/cmd/sync/prompt.go +++ b/cmd/sync/prompt.go @@ -1,11 +1,16 @@ package sync import ( + "bufio" "fmt" + "io" "os" + "strings" + "github.com/google/uuid" "github.com/spf13/cobra" "github.com/spf13/viper" + "golang.org/x/term" "github.com/launchdarkly/ldcli/cmd/cliflags" resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" @@ -21,7 +26,9 @@ import ( const ( addFlag = "add" + applyFlag = "apply" dryRunFlag = "dry-run" + yesFlag = "yes" ) type bootstrapRunner func(syncbootstrap.Options) error @@ -37,7 +44,7 @@ func newPromptCmd( cmd := &cobra.Command{ Use: "prompt", Short: "Synchronize local prompt variations with LaunchDarkly", - Long: "Bootstrap local prompt variations from LaunchDarkly, add more variations, or preview synchronization changes.", + Long: "Bootstrap local prompt variations from LaunchDarkly, add more variations, preview synchronization changes, or apply a durable sync plan.", Args: func(cmd *cobra.Command, args []string) error { if err := cobra.NoArgs(cmd, args); err != nil { return err @@ -58,6 +65,21 @@ func newPromptCmd( false, "Preview synchronization changes without creating a plan", ) + cmd.Flags().String( + applyFlag, + "", + "Apply an existing durable plan ID without planning again", + ) + cmd.Flags().Bool( + yesFlag, + false, + "Apply planned changes without interactive confirmation", + ) + cmd.Flags().String( + cliflags.ProjectFlag, + "", + "Project key for --apply when it cannot be inferred from the workspace", + ) cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) return cmd @@ -89,6 +111,39 @@ func runPrompt( } add, _ := cmd.Flags().GetBool(addFlag) dryRun, _ := cmd.Flags().GetBool(dryRunFlag) + planID, _ := cmd.Flags().GetString(applyFlag) + yes, _ := cmd.Flags().GetBool(yesFlag) + if planID != "" { + if dryRun || add { + return fmt.Errorf("--apply cannot be used with --dry-run or --add") + } + if _, err := uuid.Parse(planID); err != nil { + return fmt.Errorf("invalid plan ID %q: %w", planID, err) + } + projectKey, err := applyProjectKey(cmd, store, storeExists) + if err != nil { + return err + } + result, err := syncapi.NewClient(client).Apply( + accessToken, + baseURI, + projectKey, + planID, + nil, + ) + if err != nil { + return output.NewCmdOutputError( + err, + cliflags.GetOutputKind(cmd), + ) + } + return writeSyncOutput( + cmd.OutOrStdout(), + cliflags.GetOutputKind(cmd), + nil, + []syncapi.ProjectApply{result}, + ) + } if !storeExists || add { err := bootstrap(syncbootstrap.Options{ Catalog: syncapi.NewCatalogClient( @@ -133,10 +188,179 @@ func runPrompt( return output.NewCmdOutputError(err, cliflags.GetOutputKind(cmd)) } - return writePlanOutput( + outputKind := cliflags.GetOutputKind(cmd) + if dryRun { + return writePlanOutput(cmd.OutOrStdout(), outputKind, plans) + } + if len(plans) == 0 { + return writeSyncOutput(cmd.OutOrStdout(), outputKind, nil, nil) + } + + confirmationOutput := cmd.ErrOrStderr() + if err := writePlanReview( + confirmationOutput, + "plaintext", + plans, + terminalWidth(confirmationOutput), + ); err != nil { + return err + } + if err := validatePlansForApply(plans); err != nil { + return err + } + if !yes { + confirmed, err := confirmApply( + cmd.InOrStdin(), + confirmationOutput, + terminalStreams, + ) + if err != nil { + return err + } + if !confirmed { + _, _ = fmt.Fprintln(confirmationOutput, "Apply canceled.") + return nil + } + } + + syncClient := syncapi.NewClient(client) + applies := make([]syncapi.ProjectApply, 0, len(plans)) + for _, plan := range plans { + result, err := syncClient.Apply( + accessToken, + baseURI, + plan.ProjectKey, + plan.PlanID, + nil, + ) + if err != nil { + _ = writeCompletedSyncOutput( + cmd.OutOrStdout(), + outputKind, + plans, + applies, + ) + return output.NewCmdOutputError(err, outputKind) + } + applies = append(applies, result) + } + + return writeCompletedSyncOutput( cmd.OutOrStdout(), - cliflags.GetOutputKind(cmd), + outputKind, plans, + applies, ) } } + +func applyProjectKey( + cmd *cobra.Command, + store synclocal.Store, + storeExists bool, +) (string, error) { + if cmd.Flags().Changed(cliflags.ProjectFlag) { + projectKey, _ := cmd.Flags().GetString(cliflags.ProjectFlag) + if projectKey == "" { + return "", fmt.Errorf("--project requires a project key") + } + return projectKey, nil + } + if !storeExists { + return "", fmt.Errorf( + "--project is required when applying without a .launchdarkly workspace", + ) + } + + projectKeys, err := store.ProjectKeys() + if err != nil { + return "", err + } + switch len(projectKeys) { + case 1: + return projectKeys[0], nil + case 0: + return "", fmt.Errorf( + "--project is required because the .launchdarkly workspace has no projects", + ) + default: + return "", fmt.Errorf( + "--project is required because the .launchdarkly workspace has multiple projects", + ) + } +} + +func validatePlansForApply(plans []syncapi.ProjectPlan) error { + for _, plan := range plans { + for _, resource := range plan.Resources { + switch { + case resource.Error != nil: + return fmt.Errorf( + "cannot apply %s/%s: %s", + plan.ProjectKey, + resource.LookupKey, + resource.Error.Message, + ) + case resource.Status == syncapi.ResourceStatusConflict, + resource.Status == syncapi.ResourceStatusServerChanged: + return fmt.Errorf( + "cannot apply %s/%s with status %s; conflict selection is not supported yet", + plan.ProjectKey, + resource.LookupKey, + resource.Status, + ) + case resource.SyncDirection == syncapi.SyncDirectionServerCanonical: + return fmt.Errorf( + "cannot apply server-canonical resource %s/%s", + plan.ProjectKey, + resource.LookupKey, + ) + } + } + } + return nil +} + +type terminalCheck func(io.Reader, io.Writer) bool + +func confirmApply( + input io.Reader, + prompt io.Writer, + isTerminal terminalCheck, +) (bool, error) { + if !isTerminal(input, prompt) { + return false, fmt.Errorf( + "interactive apply confirmation requires a terminal; rerun with --yes to apply non-interactively", + ) + } + if _, err := fmt.Fprint(prompt, "\nApply these plans? [y/N] "); err != nil { + return false, err + } + answer, err := bufio.NewReader(input).ReadString('\n') + if err != nil && err != io.EOF { + return false, fmt.Errorf("read apply confirmation: %w", err) + } + answer = strings.ToLower(strings.TrimSpace(answer)) + return answer == "y" || answer == "yes", nil +} + +func terminalStreams(input io.Reader, output io.Writer) bool { + in, inputIsFile := input.(*os.File) + out, outputIsFile := output.(*os.File) + return inputIsFile && + outputIsFile && + term.IsTerminal(int(in.Fd())) && + term.IsTerminal(int(out.Fd())) +} + +func writeCompletedSyncOutput( + out io.Writer, + outputKind string, + plans []syncapi.ProjectPlan, + applies []syncapi.ProjectApply, +) error { + if outputKind == "json" { + return writeSyncOutput(out, outputKind, plans, applies) + } + return writeSyncOutput(out, outputKind, nil, applies) +} diff --git a/cmd/sync/prompt_test.go b/cmd/sync/prompt_test.go index b4eacbf5..6b37d143 100644 --- a/cmd/sync/prompt_test.go +++ b/cmd/sync/prompt_test.go @@ -148,10 +148,72 @@ func TestPromptPlansWithoutDryRunByDefault(t *testing.T) { t.Chdir(repository) t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + client := &recordingClient{ + Responses: [][]byte{ + []byte(`{ + "planId": "617c83f1-cd9a-4865-8f37-bb11f88e2147", + "expiresAt": "2026-12-14T12:00:00Z", + "resources": [] + }`), + []byte(`{ + "planId": "617c83f1-cd9a-4865-8f37-bb11f88e2147", + "status": "applied", + "appliedAt": "2026-09-19T12:00:00Z", + "resources": [] + }`), + }, + } + + stdout, _, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--yes", + "--access-token", "token", + "--base-uri", "https://example.com", + "--output", "json", + }, + ) + + require.NoError(t, err) + require.Len(t, client.Requests, 2) + + var body struct { + DryRun bool `json:"dryRun"` + } + require.NoError(t, json.Unmarshal(client.Requests[0].Body, &body)) + assert.False(t, body.DryRun) + assert.Equal( + t, + "https://example.com/api/v2/projects/project/ai-configs/sync/apply", + client.Requests[1].Path, + ) + var output struct { + Plans []map[string]any `json:"plans"` + Applies []map[string]any `json:"applies"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &output)) + require.Len(t, output.Plans, 1) + require.Len(t, output.Applies, 1) + assert.Equal( + t, + "617c83f1-cd9a-4865-8f37-bb11f88e2147", + output.Applies[0]["planId"], + ) +} + +func TestPromptAppliesExistingPlanWithInferredProject(t *testing.T) { + repository := initRepository(t) + writePrompt(t, repository, "project", "support", "default", true) + t.Chdir(repository) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + planID := "617c83f1-cd9a-4865-8f37-bb11f88e2147" client := &recordingClient{ Responses: [][]byte{[]byte(`{ - "planId": "617c83f1-cd9a-4865-8f37-bb11f88e2147", - "expiresAt": "2026-12-14T12:00:00Z", + "planId": "` + planID + `", + "status": "applied", "resources": [] }`)}, } @@ -162,21 +224,211 @@ func TestPromptPlansWithoutDryRunByDefault(t *testing.T) { analytics.NoopClientFn{}.Tracker(), []string{ "sync", "prompt", + "--apply", planID, "--access-token", "token", "--base-uri", "https://example.com", + "--output", "json", }, ) require.NoError(t, err) require.Len(t, client.Requests, 1) - + assert.Equal( + t, + "https://example.com/api/v2/projects/project/ai-configs/sync/apply", + client.Requests[0].Path, + ) + assert.NotContains(t, client.Requests[0].Path, "/sync/plan") var body struct { - DryRun bool `json:"dryRun"` + PlanID string `json:"planId"` } require.NoError(t, json.Unmarshal(client.Requests[0].Body, &body)) - assert.False(t, body.DryRun) - assert.Contains(t, string(stdout), "planId=617c83f1-cd9a-4865-8f37-bb11f88e2147") - assert.Contains(t, string(stdout), "expiresAt=2026-12-14T12:00:00Z") + assert.Equal(t, planID, body.PlanID) + var output struct { + Applies []map[string]any `json:"applies"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &output)) + require.Len(t, output.Applies, 1) +} + +func TestPromptApplyRequiresProjectForMultipleWorkspaceProjects(t *testing.T) { + repository := initRepository(t) + writePrompt(t, repository, "alpha", "support", "first", true) + writePrompt(t, repository, "zeta", "support", "second", true) + t.Chdir(repository) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + client := &recordingClient{} + + _, _, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--apply", "617c83f1-cd9a-4865-8f37-bb11f88e2147", + "--access-token", "token", + }, + ) + + require.ErrorContains(t, err, "--project is required") + assert.Empty(t, client.Requests) +} + +func TestPromptApplyUsesExplicitProjectWithoutWorkspace(t *testing.T) { + workspace := t.TempDir() + t.Chdir(workspace) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + planID := "617c83f1-cd9a-4865-8f37-bb11f88e2147" + client := &recordingClient{ + Responses: [][]byte{[]byte(`{ + "planId": "` + planID + `", + "status": "applied", + "resources": [] + }`)}, + } + + _, _, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--apply", planID, + "--project", "explicit-project", + "--access-token", "token", + "--base-uri", "https://example.com", + }, + ) + + require.NoError(t, err) + require.Len(t, client.Requests, 1) + assert.Contains(t, client.Requests[0].Path, "/projects/explicit-project/") +} + +func TestPromptApplyRejectsIncompatibleFlags(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{} + + _, _, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--apply", "617c83f1-cd9a-4865-8f37-bb11f88e2147", + "--dry-run", + "--access-token", "token", + }, + ) + + require.ErrorContains(t, err, "--apply cannot be used") + assert.Empty(t, client.Requests) +} + +func TestPromptRequiresYesForNonInteractiveApply(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(`{ + "planId": "617c83f1-cd9a-4865-8f37-bb11f88e2147", + "expiresAt": "2026-12-14T12:00:00Z", + "resources": [] + }`)}, + } + + _, _, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--access-token", "token", + "--base-uri", "https://example.com", + }, + ) + + require.ErrorContains(t, err, "rerun with --yes") + require.Len(t, client.Requests, 1) + assert.Contains(t, client.Requests[0].Path, "/sync/plan") +} + +func TestPromptDoesNotApplyUnresolvedConflict(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(`{ + "planId": "617c83f1-cd9a-4865-8f37-bb11f88e2147", + "expiresAt": "2026-12-14T12:00:00Z", + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "conflict", + "syncDirection": "both" + }] + }`)}, + } + + _, _, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--yes", + "--access-token", "token", + "--base-uri", "https://example.com", + }, + ) + + require.ErrorContains(t, err, "conflict selection is not supported yet") + require.Len(t, client.Requests, 1) +} + +func TestPromptAppliesEachProjectPlan(t *testing.T) { + repository := initRepository(t) + writePrompt(t, repository, "alpha", "support", "first", true) + writePrompt(t, repository, "zeta", "support", "second", true) + t.Chdir(repository) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + alphaPlanID := "617c83f1-cd9a-4865-8f37-bb11f88e2147" + zetaPlanID := "91929a37-79de-4eba-bc73-07c10fa87f2f" + client := &recordingClient{ + Responses: [][]byte{ + []byte(`{"planId":"` + alphaPlanID + `","expiresAt":"2026-12-14T12:00:00Z","resources":[]}`), + []byte(`{"planId":"` + zetaPlanID + `","expiresAt":"2026-12-14T12:00:00Z","resources":[]}`), + []byte(`{"planId":"` + alphaPlanID + `","status":"applied","resources":[]}`), + []byte(`{"planId":"` + zetaPlanID + `","status":"applied","resources":[]}`), + }, + } + + _, _, err := cmd.CallCmdCapturingStderr( + t, + cmd.APIClients{ResourcesClient: client}, + analytics.NoopClientFn{}.Tracker(), + []string{ + "sync", "prompt", + "--yes", + "--access-token", "token", + "--base-uri", "https://example.com", + "--output", "json", + }, + ) + + require.NoError(t, err) + require.Len(t, client.Requests, 4) + assert.Contains(t, client.Requests[0].Path, "/projects/alpha/") + assert.Contains(t, client.Requests[1].Path, "/projects/zeta/") + assert.Contains(t, client.Requests[2].Path, "/projects/alpha/") + assert.Contains(t, client.Requests[3].Path, "/projects/zeta/") + assert.Contains(t, string(client.Requests[2].Body), alphaPlanID) + assert.Contains(t, string(client.Requests[3].Body), zetaPlanID) } func TestPromptPreviewUsesLocalSourceOutsideGit(t *testing.T) { @@ -257,7 +509,8 @@ func TestPromptPreviewGroupsRequestsByProject(t *testing.T) { assert.Contains(t, client.Requests[1].Path, "/projects/zeta/") assert.Contains(t, string(stdout), "server_changed") assert.NotContains(t, string(stdout), "action=") - assert.Contains(t, string(stdout), "diff=") + assert.Contains(t, string(stdout), "Before:") + assert.Contains(t, string(stdout), "After:") } func TestPromptPreviewWithoutResourcesMakesNoRequest(t *testing.T) { diff --git a/internal/sync/api/client.go b/internal/sync/api/client.go index fbddd829..9326e19e 100644 --- a/internal/sync/api/client.go +++ b/internal/sync/api/client.go @@ -48,6 +48,54 @@ type ProjectPlan struct { Resources []PlannedResource `json:"resources"` } +type PlanStatus string + +const ( + PlanStatusApplied PlanStatus = "applied" + PlanStatusFailed PlanStatus = "failed" +) + +type ResourceApplyStatus string + +const ( + ResourceApplyStatusPending ResourceApplyStatus = "pending" + ResourceApplyStatusApplied ResourceApplyStatus = "applied" + ResourceApplyStatusFailed ResourceApplyStatus = "failed" + ResourceApplyStatusNotAttempted ResourceApplyStatus = "not_attempted" + ResourceApplyStatusReconciliationRequired ResourceApplyStatus = "reconciliation_required" +) + +type AppliedResource struct { + ResourceKind syncdomain.Kind `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Status ResourceStatus `json:"status"` + SyncDirection SyncDirection `json:"syncDirection"` + ApplyStatus ResourceApplyStatus `json:"applyStatus"` + Diff json.RawMessage `json:"diff,omitempty"` + Error *ResourceError `json:"error,omitempty"` + ApplyError *ResourceError `json:"applyError,omitempty"` +} + +type ProjectApply struct { + ProjectKey string `json:"-"` + PlanID string `json:"planId"` + Status PlanStatus `json:"status"` + AppliedAt string `json:"appliedAt,omitempty"` + Error *ResourceError `json:"error,omitempty"` + Resources []AppliedResource `json:"resources"` +} + +type ConflictResolution struct { + ResourceKind syncdomain.Kind `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Resolution string `json:"resolution"` +} + +type applyRequest struct { + PlanID string `json:"planId"` + Resolutions []ConflictResolution `json:"resolutions,omitempty"` +} + type planRequest struct { Source sourceRequest `json:"source"` DryRun bool `json:"dryRun"` @@ -100,6 +148,57 @@ func (client Client) Plan( return plans, nil } +func (client Client) Apply( + accessToken string, + baseURI string, + projectKey string, + planID string, + resolutions []ConflictResolution, +) (ProjectApply, error) { + body, err := json.MarshalIndent(applyRequest{ + PlanID: planID, + Resolutions: resolutions, + }, "", " ") + if err != nil { + return ProjectApply{}, fmt.Errorf("marshal apply request: %w", err) + } + + endpoint, err := url.JoinPath( + baseURI, + "api/v2/projects", + projectKey, + "ai-configs/sync/apply", + ) + if err != nil { + return ProjectApply{}, fmt.Errorf("build apply endpoint: %w", err) + } + + response, err := client.transport.MakeRequest( + accessToken, + http.MethodPost, + endpoint, + "application/json", + nil, + body, + false, + ) + if err != nil { + return ProjectApply{}, err + } + + var result ProjectApply + if err := json.Unmarshal(response, &result); err != nil { + return ProjectApply{}, fmt.Errorf("decode apply response: %w", err) + } + if result.PlanID == "" || result.Status == "" { + return ProjectApply{}, fmt.Errorf( + "decode apply response: planId and status are required", + ) + } + result.ProjectKey = projectKey + return result, nil +} + func (client Client) planProject( accessToken string, baseURI string, diff --git a/internal/sync/api/client_test.go b/internal/sync/api/client_test.go index 85a4ebf7..7552a374 100644 --- a/internal/sync/api/client_test.go +++ b/internal/sync/api/client_test.go @@ -245,6 +245,88 @@ func TestClientPlanRejectsInvalidResponse(t *testing.T) { require.ErrorContains(t, err, "decode plan response") } +func TestClientApply(t *testing.T) { + transport := &recordingClient{ + Responses: [][]byte{[]byte(`{ + "planId": "617c83f1-cd9a-4865-8f37-bb11f88e2147", + "status": "applied", + "appliedAt": "2026-09-19T12:00:00Z", + "resources": [{ + "resourceKind": "variation", + "lookupKey": "config/first", + "status": "conflict", + "syncDirection": "both", + "applyStatus": "applied" + }] + }`)}, + } + client := NewClient(transport) + resolutions := []ConflictResolution{{ + ResourceKind: syncdomain.KindVariation, + LookupKey: "config/first", + Resolution: "use_local", + }} + + result, err := client.Apply( + "token", + "https://example.com", + "project", + "617c83f1-cd9a-4865-8f37-bb11f88e2147", + resolutions, + ) + + require.NoError(t, err) + require.Len(t, transport.Requests, 1) + request := transport.Requests[0] + assert.Equal(t, "POST", request.Method) + assert.Equal( + t, + "https://example.com/api/v2/projects/project/ai-configs/sync/apply", + request.Path, + ) + assert.Equal(t, "application/json", request.ContentType) + var body applyRequest + require.NoError(t, json.Unmarshal(request.Body, &body)) + assert.Equal(t, "617c83f1-cd9a-4865-8f37-bb11f88e2147", body.PlanID) + assert.Equal(t, resolutions, body.Resolutions) + assert.Equal(t, "project", result.ProjectKey) + assert.Equal(t, PlanStatusApplied, result.Status) + require.Len(t, result.Resources, 1) + assert.Equal(t, ResourceApplyStatusApplied, result.Resources[0].ApplyStatus) +} + +func TestClientApplyRejectsInvalidResponse(t *testing.T) { + client := NewClient(&recordingClient{ + Responses: [][]byte{[]byte(`{"resources":[]}`)}, + }) + + _, err := client.Apply( + "token", + "https://example.com", + "project", + "617c83f1-cd9a-4865-8f37-bb11f88e2147", + nil, + ) + + require.ErrorContains(t, err, "planId and status are required") +} + +func TestClientApplyReturnsTransportError(t *testing.T) { + client := NewClient(&recordingClient{ + Err: errors.New("sync plan has expired"), + }) + + _, err := client.Apply( + "token", + "https://example.com", + "project", + "617c83f1-cd9a-4865-8f37-bb11f88e2147", + nil, + ) + + require.ErrorContains(t, err, "sync plan has expired") +} + func variationResource( projectKey string, lookupKey string, diff --git a/internal/sync/local/store.go b/internal/sync/local/store.go index a9e2a61c..38651576 100644 --- a/internal/sync/local/store.go +++ b/internal/sync/local/store.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" syncdomain "github.com/launchdarkly/ldcli/internal/sync" @@ -49,6 +50,22 @@ func (s Store) Exists() (bool, error) { return true, nil } +func (s Store) ProjectKeys() ([]string, error) { + entries, err := os.ReadDir(s.root) + if err != nil { + return nil, fmt.Errorf("read %s: %w", s.root, err) + } + + var keys []string + for _, entry := range entries { + if entry.IsDir() { + keys = append(keys, entry.Name()) + } + } + slices.Sort(keys) + return keys, nil +} + func (s Store) VariationExists(projectKey, configKey, variationKey string) (bool, error) { path, err := s.variationPath(projectKey, configKey, variationKey) if err != nil { diff --git a/internal/sync/local/store_test.go b/internal/sync/local/store_test.go index a49dfb8e..e80dbf77 100644 --- a/internal/sync/local/store_test.go +++ b/internal/sync/local/store_test.go @@ -12,6 +12,29 @@ import ( syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) +func TestStore_ProjectKeys(t *testing.T) { + root := t.TempDir() + store := NewStore(root) + require.NoError(t, os.MkdirAll( + filepath.Join(root, syncdomain.RootDir, "zeta"), + 0o755, + )) + require.NoError(t, os.MkdirAll( + filepath.Join(root, syncdomain.RootDir, "alpha"), + 0o755, + )) + require.NoError(t, os.WriteFile( + filepath.Join(root, syncdomain.RootDir, "README"), + nil, + 0o644, + )) + + keys, err := store.ProjectKeys() + + require.NoError(t, err) + assert.Equal(t, []string{"alpha", "zeta"}, keys) +} + func TestStore_BootstrapRoundTripsSupportedModes(t *testing.T) { root := t.TempDir() resources := []VariationFile{