From 2dd9944390683bc882344e21dd0a1763a9245979 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Wed, 23 Sep 2026 15:02:43 -0400 Subject: [PATCH] feat(sync): apply prompt sync plans --- cmd/sync/output.go | 894 +++++++++++++++++++++++++++++-- cmd/sync/output_test.go | 556 +++++++++++++++++++ cmd/sync/prompt.go | 840 ++++++++++++++++++++++++++++- cmd/sync/prompt_test.go | 890 +++++++++++++++++++++++++++++- go.mod | 2 +- internal/sync/api/client.go | 147 ++++- internal/sync/api/client_test.go | 146 ++++- 7 files changed, 3373 insertions(+), 102 deletions(-) create mode 100644 cmd/sync/output_test.go diff --git a/cmd/sync/output.go b/cmd/sync/output.go index b9c3a252..5ec109b7 100644 --- a/cmd/sync/output.go +++ b/cmd/sync/output.go @@ -1,21 +1,33 @@ package sync import ( + "bytes" "encoding/json" "fmt" "io" + "os" + "slices" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/pmezard/go-difflib/difflib" + "golang.org/x/term" - "github.com/launchdarkly/ldcli/internal/output" syncapi "github.com/launchdarkly/ldcli/internal/sync/api" ) type planOutputResource struct { - ResourceKind string `json:"resourceKind"` - LookupKey string `json:"lookupKey"` - Status syncapi.ResourceStatus `json:"status"` - SyncDirection syncapi.SyncDirection `json:"syncDirection"` - Diff json.RawMessage `json:"diff,omitempty"` - Error *syncapi.ResourceError `json:"error,omitempty"` + ResourceKind string `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Status syncapi.ResourceStatus `json:"status"` + SyncDirection syncapi.SyncDirection `json:"syncDirection"` + ManifestUpdateRequired bool `json:"manifestUpdateRequired"` + LocalDeleted bool `json:"localDeleted,omitempty"` + ServerDeleted bool `json:"serverDeleted,omitempty"` + Diff json.RawMessage `json:"diff,omitempty"` + Error *syncapi.ResourceError `json:"error,omitempty"` } type projectPlanOutput struct { @@ -25,13 +37,32 @@ 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"` + Outcome syncapi.ResourceApplyOutcome `json:"outcome"` + Error *syncapi.ResourceError `json:"error,omitempty"` +} + +type projectApplyOutput struct { + ProjectKey string `json:"projectKey"` + PlanID string `json:"planId"` + Status syncapi.PlanStatus `json:"status"` + Error *syncapi.ResourceError `json:"error,omitempty"` + Resources []applyOutputResource `json:"resources"` +} + +type pulledResourceOutput struct { + ProjectKey string `json:"projectKey"` + LookupKey string `json:"lookupKey"` + Path string `json:"path"` + Deleted bool `json:"deleted,omitempty"` } -type planOutputItem struct { - Key string `json:"key"` - Name string `json:"name"` +type syncOutput struct { + Pulls []pulledResourceOutput `json:"pulls,omitempty"` + Plans []projectPlanOutput `json:"plans,omitempty"` + Applies []projectApplyOutput `json:"applies,omitempty"` } func writePlanOutput( @@ -39,31 +70,89 @@ 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) + } + if outputKind != "plaintext" && outputKind != "markdown" { + return fmt.Errorf("unsupported output kind %q", outputKind) } + return writePlanReview(out, outputKind, plans, terminalWidth(out)) +} - data, err := json.Marshal(outputValue) - if err != nil { - return fmt.Errorf("marshal plan output: %w", err) +func writeSyncOutput( + out io.Writer, + outputKind string, + pulls []serverPull, + plans []syncapi.ProjectPlan, + applies []syncapi.ProjectApply, +) error { + if outputKind == "" { + outputKind = "plaintext" + } + if outputKind == "json" { + return writeJSON(out, syncOutput{ + Pulls: newPulledResourceOutputs(pulls), + Plans: newProjectPlanOutputs(plans), + Applies: newProjectApplyOutputs(applies), + }) + } + if outputKind != "plaintext" && outputKind != "markdown" { + return fmt.Errorf("unsupported output kind %q", outputKind) } - formatted, err := output.CmdOutput("list", outputKind, data) - if err != nil { - return err + if len(pulls) != 0 { + writePullResults(out, outputKind, pulls) + if len(plans) != 0 || len(applies) != 0 { + _, _ = fmt.Fprintln(out) + } } - if formatted == "" { - 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) +} - if _, err := fmt.Fprintln(out, formatted); err != nil { - return fmt.Errorf("write plan output: %w", err) +func newPulledResourceOutputs(pulls []serverPull) []pulledResourceOutput { + result := make([]pulledResourceOutput, 0, len(pulls)) + for _, pull := range pulls { + result = append(result, pulledResourceOutput{ + ProjectKey: pull.ProjectKey, + LookupKey: pull.LookupKey, + Path: pull.Path, + Deleted: pull.Action == deleteLocalFile, + }) } + return result +} - return nil +func writePullResults(out io.Writer, outputKind string, pulls []serverPull) { + if outputKind == "markdown" { + _, _ = fmt.Fprintln(out, "## Pulled server changes") + } else { + _, _ = fmt.Fprintln(out, "Pulled server changes:") + } + for _, pull := range pulls { + target := ".launchdarkly/" + pull.Path + if pull.Action == deleteLocalFile { + target = "removed " + target + } + _, _ = fmt.Fprintf( + out, + "- %s/%s -> %s\n", + pull.ProjectKey, + pull.LookupKey, + target, + ) + } } func newProjectPlanOutputs(plans []syncapi.ProjectPlan) []projectPlanOutput { @@ -77,12 +166,15 @@ func newProjectPlanOutputs(plans []syncapi.ProjectPlan) []projectPlanOutput { } for _, resource := range plan.Resources { outputPlan.Resources = append(outputPlan.Resources, planOutputResource{ - ResourceKind: string(resource.ResourceKind), - LookupKey: resource.LookupKey, - Status: resource.Status, - SyncDirection: resource.SyncDirection, - Diff: resource.Diff, - Error: resource.Error, + ResourceKind: string(resource.ResourceKind), + LookupKey: resource.LookupKey, + Status: resource.Status, + SyncDirection: resource.SyncDirection, + ManifestUpdateRequired: resource.ManifestUpdateRequired, + LocalDeleted: resource.LocalDeleted, + ServerDeleted: resource.ServerDeleted, + Diff: resource.Diff, + Error: resource.Error, }) } outputPlans = append(outputPlans, outputPlan) @@ -91,44 +183,730 @@ 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, + 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, + Outcome: resource.Outcome, + Error: resource.Error, }) } + 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\nExpires: %s\n", + plan.PlanID, + formatExpiration(plan.ExpiresAt), ) - if len(resource.Diff) > 0 { - details += " diff=" + string(resource.Diff) + } + for _, resource := range plan.Resources { + presentation := presentPlanResource(resource) + if outputKind == "markdown" { + _, _ = fmt.Fprintf( + out, + "\n### `%s`\n\nStatus: **%s**\n", + resource.LookupKey, + presentation.status, + ) + } else { + _, _ = fmt.Fprintf( + out, + "\n%s\n Status: %s\n", + resource.LookupKey, + presentation.status, + ) + } + if presentation.action != "" { + if outputKind == "markdown" { + _, _ = fmt.Fprintf(out, "Action: %s\n", presentation.action) + } else { + _, _ = fmt.Fprintf(out, " Action: %s\n", presentation.action) + } } 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, + presentation.diff, + ) + if err != nil { + return err + } + _, _ = fmt.Fprint(out, rendered) + } + } + } + return nil +} - items = append(items, planOutputItem{ - Key: plan.ProjectKey + "/" + resource.LookupKey, - Name: details, - }) +func formatExpiration(value string) string { + return formatExpirationIn(value, time.Local) +} + +func formatExpirationIn(value string, location *time.Location) string { + expiresAt, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return value + } + return expiresAt.In(location).Format("January 2, 2006 at 3:04 PM MST") +} + +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 outcome=%s\n", + resource.LookupKey, + resource.Outcome, + ) + if resource.Error != nil { + _, _ = fmt.Fprintf( + out, + " Error: %s: %s\n", + resource.Error.Code, + resource.Error.Message, + ) + } } } + return nil +} + +type planResourcePresentation struct { + status string + action string + diff variationDiffPresentation +} - return items +type variationDiffPresentation struct { + beforeLabel string + afterLabel string + reverse bool +} + +func presentPlanResource( + resource syncapi.PlannedResource, +) planResourcePresentation { + switch resource.Status { + case syncapi.ResourceStatusInSync: + return presentInSyncResource(resource) + case syncapi.ResourceStatusLocalChanged: + return presentLocalChange(resource) + case syncapi.ResourceStatusServerChanged: + return presentServerChange(resource) + case syncapi.ResourceStatusConflict: + return presentConflict(resource) + default: + return planResourcePresentation{ + status: string(resource.Status), + diff: currentSourcesDiff(), + } + } +} + +func presentInSyncResource( + resource syncapi.PlannedResource, +) planResourcePresentation { + status := "In sync" + if resource.LocalDeleted && resource.ServerDeleted { + status = "Removed locally and from LaunchDarkly" + } + return planResourcePresentation{ + status: status, + diff: currentSourcesDiff(), + } +} + +func presentLocalChange( + resource syncapi.PlannedResource, +) planResourcePresentation { + if resource.SyncDirection == syncapi.SyncDirectionServerCanonical { + if resource.LocalDeleted { + return planResourcePresentation{ + status: "Local changes detected", + action: "Update the local file from LaunchDarkly.", + diff: localFileFromServerDiff(), + } + } + return planResourcePresentation{ + status: "Local changes detected", + action: "No automatic change; LaunchDarkly is authoritative.", + diff: currentSourcesDiff(), + } + } + + if resource.LocalDeleted { + return planResourcePresentation{ + status: "Local file removed", + action: "Delete the variation from LaunchDarkly.", + diff: deleteServerVariationDiff(), + } + } + return planResourcePresentation{ + status: "Local changes detected", + action: "Update LaunchDarkly from the local file.", + diff: serverFromLocalFileDiff(), + } +} + +func presentServerChange( + resource syncapi.PlannedResource, +) planResourcePresentation { + if resource.SyncDirection == syncapi.SyncDirectionCodeCanonical { + if resource.ServerDeleted { + return planResourcePresentation{ + status: "LaunchDarkly changes detected", + action: "Update LaunchDarkly from the local file.", + diff: serverFromLocalFileDiff(), + } + } + return planResourcePresentation{ + status: "LaunchDarkly changes detected", + action: "No automatic change; the local file is authoritative.", + diff: currentSourcesDiff(), + } + } + + if resource.ServerDeleted { + return planResourcePresentation{ + status: "LaunchDarkly variation removed", + action: "Delete the local file.", + diff: deleteLocalFileDiff(), + } + } + return planResourcePresentation{ + status: "LaunchDarkly changes detected", + action: "Update the local file from LaunchDarkly.", + diff: localFileFromServerDiff(), + } +} + +func presentConflict( + resource syncapi.PlannedResource, +) planResourcePresentation { + isDeletion := resource.LocalDeleted || resource.ServerDeleted + status := "Conflict" + if isDeletion { + status = "Deletion conflict" + } + + switch { + case isDeletion && + resource.SyncDirection == syncapi.SyncDirectionCodeCanonical: + return planResourcePresentation{ + status: status, + action: "Resolve using the local file because it is authoritative.", + diff: serverFromLocalFileDiff(), + } + case isDeletion && + resource.SyncDirection == syncapi.SyncDirectionServerCanonical: + return planResourcePresentation{ + status: status, + action: "Resolve using LaunchDarkly because it is authoritative.", + diff: localFileFromServerDiff(), + } + default: + return planResourcePresentation{ + status: status, + action: "No automatic change; resolve the conflict first.", + diff: currentSourcesDiff(), + } + } +} + +func currentSourcesDiff() variationDiffPresentation { + return variationDiffPresentation{ + beforeLabel: "LaunchDarkly now", + afterLabel: "Local file now", + } +} + +func serverFromLocalFileDiff() variationDiffPresentation { + return variationDiffPresentation{ + beforeLabel: "LaunchDarkly now", + afterLabel: "LaunchDarkly after sync (from local file)", + } +} + +func localFileFromServerDiff() variationDiffPresentation { + return variationDiffPresentation{ + beforeLabel: "Local file now", + afterLabel: "Local file after sync (from LaunchDarkly)", + reverse: true, + } +} + +func deleteServerVariationDiff() variationDiffPresentation { + return variationDiffPresentation{ + beforeLabel: "LaunchDarkly now", + afterLabel: "LaunchDarkly after sync", + } +} + +func deleteLocalFileDiff() variationDiffPresentation { + return variationDiffPresentation{ + beforeLabel: "Local file now", + afterLabel: "Local file after sync", + reverse: true, + } +} + +type variationFieldDiff struct { + Before json.RawMessage `json:"before"` + After json.RawMessage `json:"after"` +} + +func renderVariationDiff( + payload json.RawMessage, + outputKind string, + width int, + presentation variationDiffPresentation, +) (string, error) { + fields := make(map[string]variationFieldDiff) + if err := json.Unmarshal(payload, &fields); err != nil { + return "", fmt.Errorf("decode variation diff: %w", err) + } + fields, err := collapseWholeVariationDiff(fields) + if err != nil { + return "", err + } + keys := make([]string, 0, len(fields)) + for key := range fields { + keys = append(keys, key) + } + slices.Sort(keys) + + var rendered strings.Builder + for _, key := range keys { + diff := fields[key] + if presentation.reverse { + diff.Before, diff.After = diff.After, diff.Before + } + 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" + } + + diffLines, err := unifiedDiffLines( + before, + after, + presentation.beforeLabel, + presentation.afterLabel, + ) + if err != nil { + return "", err + } + if outputKind == "markdown" { + _, _ = fmt.Fprintf(&rendered, "\n#### %s (%s)\n\n", key, change) + _, _ = fmt.Fprintf( + &rendered, + "```diff\n%s\n```\n", + strings.Join(diffLines, "\n"), + ) + continue + } + _, _ = fmt.Fprintf(&rendered, "\n %s (%s)\n", key, change) + if width >= 100 { + rendered.WriteString(renderSideBySideUnifiedDiff(diffLines, width)) + } else { + rendered.WriteString(renderUnifiedDiff(diffLines, width > 0)) + } + } + return rendered.String(), nil +} + +func collapseWholeVariationDiff( + fields map[string]variationFieldDiff, +) (map[string]variationFieldDiff, error) { + if len(fields) == 0 { + return fields, nil + } + + allAdded := true + allRemoved := true + for _, diff := range fields { + allAdded = allAdded && len(diff.Before) == 0 + allRemoved = allRemoved && len(diff.After) == 0 + } + if !allAdded && !allRemoved { + return fields, nil + } + + values := make(map[string]json.RawMessage, len(fields)) + for key, diff := range fields { + if allAdded { + values[key] = diff.After + } else { + values[key] = diff.Before + } + } + value, err := json.Marshal(values) + if err != nil { + return nil, fmt.Errorf("combine variation diff: %w", err) + } + + combined := variationFieldDiff{} + if allAdded { + combined.After = value + } else { + combined.Before = value + } + return map[string]variationFieldDiff{"variation": combined}, nil +} + +func unifiedDiffLines( + before string, + after string, + beforeLabel string, + afterLabel string, +) ([]string, error) { + diff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: diffInputLines(before), + B: diffInputLines(after), + FromFile: beforeLabel, + ToFile: afterLabel, + Context: 3, + }) + if err != nil { + return nil, fmt.Errorf("build variation diff: %w", err) + } + return strings.Split(strings.TrimSuffix(diff, "\n"), "\n"), nil +} + +func diffInputLines(value string) []string { + lines := strings.Split(value, "\n") + for index := range lines { + lines[index] += "\n" + } + return lines +} + +func renderUnifiedDiff(lines []string, color bool) string { + var rendered strings.Builder + for index := 0; index < len(lines); { + change := scanDiffChange(lines, index) + if len(change.removed) != 0 && len(change.added) != 0 { + styledRemoved := append([]string(nil), change.removed...) + styledAdded := append([]string(nil), change.added...) + pairs := min(len(change.removed), len(change.added)) + for pair := 0; pair < pairs; pair++ { + if color { + styledRemoved[pair], styledAdded[pair] = renderChangedLinePair( + change.removed[pair], + change.added[pair], + ) + } + } + for index := pairs; index < len(styledRemoved); index++ { + styledRemoved[index] = styleDiffLine(styledRemoved[index], color) + } + for index := pairs; index < len(styledAdded); index++ { + styledAdded[index] = styleDiffLine(styledAdded[index], color) + } + for _, line := range append(styledRemoved, styledAdded...) { + _, _ = fmt.Fprintf(&rendered, " %s\n", line) + } + index = change.next + continue + } + _, _ = fmt.Fprintf( + &rendered, + " %s\n", + styleDiffLine(lines[index], color), + ) + index++ + } + return rendered.String() +} + +func renderSideBySideUnifiedDiff(lines []string, width int) string { + if len(lines) < 2 { + return renderUnifiedDiff(lines, true) + } + + const ( + indentWidth = 4 + columnGap = 2 + ) + columnWidth := (width - indentWidth - columnGap) / 2 + cellStyle := lipgloss.NewStyle().Width(columnWidth) + + var rendered strings.Builder + writeRow := func(before, after string) { + before = ansi.Wordwrap(before, columnWidth, ",:") + after = ansi.Wordwrap(after, columnWidth, ",:") + row := lipgloss.JoinHorizontal( + lipgloss.Top, + cellStyle.Render(before), + strings.Repeat(" ", columnGap), + cellStyle.Render(after), + ) + _, _ = fmt.Fprintf( + &rendered, + "%s\n", + indentBlock(row, indentWidth), + ) + } + + writeRow(styleDiffLine(lines[0], true), styleDiffLine(lines[1], true)) + for index := 2; index < len(lines); { + if strings.HasPrefix(lines[index], "@@") { + _, _ = fmt.Fprintf( + &rendered, + " %s\n", + styleDiffLine(lines[index], true), + ) + index++ + continue + } + + change := scanDiffChange(lines, index) + if len(change.removed) != 0 || len(change.added) != 0 { + for pair := 0; pair < max(len(change.removed), len(change.added)); pair++ { + var beforeLine, afterLine string + switch { + case pair < len(change.removed) && pair < len(change.added): + beforeLine, afterLine = renderChangedLinePair( + change.removed[pair], + change.added[pair], + ) + case pair < len(change.removed): + beforeLine = styleDiffLine(change.removed[pair], true) + default: + afterLine = styleDiffLine(change.added[pair], true) + } + writeRow(beforeLine, afterLine) + } + index = change.next + continue + } + + context := styleDiffLine(lines[index], true) + writeRow(context, context) + index++ + } + return rendered.String() +} + +type diffChange struct { + removed []string + added []string + next int +} + +func scanDiffChange(lines []string, start int) diffChange { + removedEnd := start + for removedEnd < len(lines) && isRemovedDiffLine(lines[removedEnd]) { + removedEnd++ + } + + addedEnd := removedEnd + for addedEnd < len(lines) && isAddedDiffLine(lines[addedEnd]) { + addedEnd++ + } + + return diffChange{ + removed: lines[start:removedEnd], + added: lines[removedEnd:addedEnd], + next: addedEnd, + } +} + +func indentBlock(value string, spaces int) string { + prefix := strings.Repeat(" ", spaces) + return prefix + strings.ReplaceAll(value, "\n", "\n"+prefix) +} + +func isRemovedDiffLine(line string) bool { + return strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") +} + +func isAddedDiffLine(line string) bool { + return strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") +} + +func styleDiffLine(line string, color bool) string { + if !color { + return line + } + switch { + case strings.HasPrefix(line, "---"), isRemovedDiffLine(line): + return lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(line) + case strings.HasPrefix(line, "+++"), isAddedDiffLine(line): + return lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(line) + case strings.HasPrefix(line, "@@"): + return lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Render(line) + default: + return lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(line) + } +} + +func renderChangedLinePair(before, after string) (string, string) { + if !isRemovedDiffLine(before) || !isAddedDiffLine(after) { + return styleDiffLine(before, true), styleDiffLine(after, true) + } + + prefix, removed, added, suffix := changedParts(before[1:], after[1:]) + removedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + addedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + removedHighlight := removedStyle. + Background(lipgloss.Color("52")). + Bold(true) + addedHighlight := addedStyle. + Background(lipgloss.Color("22")). + Bold(true) + + return removedStyle.Render("-"+prefix) + + removedHighlight.Render(removed) + + removedStyle.Render(suffix), + addedStyle.Render("+"+prefix) + + addedHighlight.Render(added) + + addedStyle.Render(suffix) +} + +func changedParts(before, after string) ( + prefix string, + removed string, + added string, + suffix string, +) { + beforeRunes := []rune(before) + afterRunes := []rune(after) + prefixLength := 0 + for prefixLength < min(len(beforeRunes), len(afterRunes)) && + beforeRunes[prefixLength] == afterRunes[prefixLength] { + prefixLength++ + } + + suffixLength := 0 + for suffixLength < len(beforeRunes)-prefixLength && + suffixLength < len(afterRunes)-prefixLength && + beforeRunes[len(beforeRunes)-1-suffixLength] == + afterRunes[len(afterRunes)-1-suffixLength] { + suffixLength++ + } + + beforeChangeEnd := len(beforeRunes) - suffixLength + afterChangeEnd := len(afterRunes) - suffixLength + return string(beforeRunes[:prefixLength]), + string(beforeRunes[prefixLength:beforeChangeEnd]), + string(afterRunes[prefixLength:afterChangeEnd]), + string(beforeRunes[beforeChangeEnd:]) +} + +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 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..365bca3b --- /dev/null +++ b/cmd/sync/output_test.go @@ -0,0 +1,556 @@ +package sync + +import ( + "bytes" + "encoding/json" + "io" + "strings" + "testing" + "time" + + "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(), "Sync these changes?") + } + }) + } +} + +func TestIsServerPull(t *testing.T) { + for _, test := range []struct { + name string + status syncapi.ResourceStatus + direction syncapi.SyncDirection + hasError bool + localDeleted bool + want bool + }{ + { + name: "both", + status: syncapi.ResourceStatusServerChanged, + direction: syncapi.SyncDirectionBoth, + want: true, + }, + { + name: "server canonical", + status: syncapi.ResourceStatusServerChanged, + direction: syncapi.SyncDirectionServerCanonical, + want: true, + }, + { + name: "server canonical local change", + status: syncapi.ResourceStatusLocalChanged, + direction: syncapi.SyncDirectionServerCanonical, + localDeleted: true, + want: true, + }, + { + name: "server canonical conflict", + status: syncapi.ResourceStatusConflict, + direction: syncapi.SyncDirectionServerCanonical, + localDeleted: true, + want: true, + }, + { + name: "server canonical unrelated local change", + status: syncapi.ResourceStatusLocalChanged, + direction: syncapi.SyncDirectionServerCanonical, + }, + { + name: "code canonical", + status: syncapi.ResourceStatusServerChanged, + direction: syncapi.SyncDirectionCodeCanonical, + }, + { + name: "conflict", + status: syncapi.ResourceStatusConflict, + direction: syncapi.SyncDirectionBoth, + }, + { + name: "resource error", + status: syncapi.ResourceStatusServerChanged, + direction: syncapi.SyncDirectionBoth, + hasError: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + resource := syncapi.PlannedResource{ + ResourceKind: syncdomain.KindVariation, + Status: test.status, + SyncDirection: test.direction, + LocalDeleted: test.localDeleted, + } + if test.hasError { + resource.Error = &syncapi.ResourceError{Message: "blocked"} + } + + assert.Equal(t, test.want, isServerPull(resource)) + }) + } +} + +func TestValidatePlansForApplyAllowsInSyncServerCanonicalResource(t *testing.T) { + plans := []syncapi.ProjectPlan{{ + ProjectKey: "project", + Resources: []syncapi.PlannedResource{{ + ResourceKind: syncdomain.KindVariation, + LookupKey: "support/default", + Status: syncapi.ResourceStatusInSync, + SyncDirection: syncapi.SyncDirectionServerCanonical, + }}, + }} + + require.NoError(t, validatePlansForApply(plans)) + + plans[0].Resources[0].Status = syncapi.ResourceStatusLocalChanged + require.ErrorContains(t, validatePlansForApply(plans), "server-canonical") +} + +func TestCodeCanonicalServerDeletionUsesLocalState(t *testing.T) { + plan := syncapi.ProjectPlan{ + ProjectKey: "project", + Resources: []syncapi.PlannedResource{ + { + ResourceKind: syncdomain.KindVariation, + LookupKey: "support/deleted-on-server", + Status: syncapi.ResourceStatusServerChanged, + SyncDirection: syncapi.SyncDirectionCodeCanonical, + ServerDeleted: true, + }, + }, + } + + require.NoError(t, validatePlansForSync([]syncapi.ProjectPlan{plan})) + require.NoError(t, validatePlansForApply([]syncapi.ProjectPlan{plan})) + + plan.Resources = append(plan.Resources, syncapi.PlannedResource{ + ResourceKind: syncdomain.KindVariation, + LookupKey: "support/conflict", + Status: syncapi.ResourceStatusConflict, + SyncDirection: syncapi.SyncDirectionCodeCanonical, + LocalDeleted: true, + }) + require.ErrorContains(t, validatePlansForSync([]syncapi.ProjectPlan{plan}), "conflict") + require.ErrorContains(t, validatePlansForApply([]syncapi.ProjectPlan{plan}), "conflict") +} + +func TestFormatExpiration(t *testing.T) { + assert.Equal( + t, + "December 14, 2026 at 7:00 AM EST", + formatExpirationIn( + "2026-12-14T12:00:00Z", + time.FixedZone("EST", -5*60*60), + ), + ) + assert.Equal( + t, + "unknown", + formatExpirationIn("unknown", time.UTC), + ) +} + +func TestWritePlanReviewExplainsWhichSideWillChange(t *testing.T) { + var output bytes.Buffer + err := writePlanReview( + &output, + "plaintext", + []syncapi.ProjectPlan{{ + ProjectKey: "project", + Resources: []syncapi.PlannedResource{ + { + ResourceKind: syncdomain.KindVariation, + LookupKey: "support/server-change", + Status: syncapi.ResourceStatusServerChanged, + SyncDirection: syncapi.SyncDirectionBoth, + Diff: json.RawMessage( + `{"name":{"before":"Server value","after":"Local value"}}`, + ), + }, + { + ResourceKind: syncdomain.KindVariation, + LookupKey: "support/local-change", + Status: syncapi.ResourceStatusLocalChanged, + SyncDirection: syncapi.SyncDirectionBoth, + Diff: json.RawMessage( + `{"name":{"before":"Server old","after":"Local new"}}`, + ), + }, + { + ResourceKind: syncdomain.KindVariation, + LookupKey: "support/unchanged", + Status: syncapi.ResourceStatusInSync, + SyncDirection: syncapi.SyncDirectionBoth, + }, + { + ResourceKind: syncdomain.KindVariation, + LookupKey: "support/local-deleted", + Status: syncapi.ResourceStatusLocalChanged, + SyncDirection: syncapi.SyncDirectionBoth, + LocalDeleted: true, + Diff: json.RawMessage( + `{"name":{"before":"Deleted locally"}}`, + ), + }, + { + ResourceKind: syncdomain.KindVariation, + LookupKey: "support/server-deleted", + Status: syncapi.ResourceStatusServerChanged, + SyncDirection: syncapi.SyncDirectionBoth, + ServerDeleted: true, + Diff: json.RawMessage( + `{"name":{"after":"Deleted on server"}}`, + ), + }, + }, + }}, + 0, + ) + + require.NoError(t, err) + rendered := output.String() + assert.NotContains(t, rendered, "Direction:") + assert.NotContains(t, rendered, "server_changed") + assert.NotContains(t, rendered, "local_changed") + + assert.Contains(t, rendered, "Status: LaunchDarkly changes detected") + assert.Contains(t, rendered, "Action: Update the local file from LaunchDarkly.") + assert.Contains(t, rendered, "--- Local file now") + assert.Contains(t, rendered, "+++ Local file after sync (from LaunchDarkly)") + assert.Contains(t, rendered, `-"Local value"`) + assert.Contains(t, rendered, `+"Server value"`) + + assert.Contains(t, rendered, "Status: Local changes detected") + assert.Contains(t, rendered, "Action: Update LaunchDarkly from the local file.") + assert.Contains(t, rendered, "--- LaunchDarkly now") + assert.Contains(t, rendered, "+++ LaunchDarkly after sync (from local file)") + assert.Contains(t, rendered, `-"Server old"`) + assert.Contains(t, rendered, `+"Local new"`) + assert.Contains(t, rendered, "Status: In sync") + assert.Contains(t, rendered, "Status: Local file removed") + assert.Contains(t, rendered, "Action: Delete the variation from LaunchDarkly.") + assert.Contains(t, rendered, "Status: LaunchDarkly variation removed") + assert.Contains(t, rendered, "Action: Delete the local file.") +} + +func TestWriteApplyResultsShowsResourceOutcomes(t *testing.T) { + var output bytes.Buffer + err := writeApplyResults( + &output, + "plaintext", + []syncapi.ProjectApply{{ + ProjectKey: "project", + PlanID: "617c83f1-cd9a-4865-8f37-bb11f88e2147", + Status: syncapi.PlanStatusApplied, + Resources: []syncapi.AppliedResource{ + { + LookupKey: "support/unchanged", + Outcome: syncapi.ResourceApplyOutcomeApplied, + }, + { + LookupKey: "support/changed", + Outcome: syncapi.ResourceApplyOutcomeApplied, + }, + { + LookupKey: "support/failed", + Outcome: syncapi.ResourceApplyOutcomeFailed, + Error: &syncapi.ResourceError{ + Code: "manifest_changed", + Message: "manifest changed", + }, + }, + }, + }}, + ) + + require.NoError(t, err) + assert.Contains(t, output.String(), "support/unchanged outcome=applied") + assert.Contains(t, output.String(), "support/changed outcome=applied") + assert.Contains(t, output.String(), "support/failed outcome=failed") + assert.Contains(t, output.String(), "manifest_changed: manifest changed") +} + +func TestRenderVariationDiffSortsFieldsAndUsesUnifiedFallback(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, + variationDiffPresentation{beforeLabel: "Before", afterLabel: "After"}, + ) + + 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, "\n instructions (added)") + assert.Contains(t, rendered, "\n\n model (changed)") + assert.Contains(t, rendered, "--- Before") + assert.Contains(t, rendered, "+++ After") + assert.Contains(t, rendered, "-(not present)") + assert.Contains(t, rendered, `+"Be helpful"`) + assert.Contains(t, rendered, `- "temperature": 0.2`) + assert.Contains(t, rendered, `+ "temperature": 0.3`) + assert.NotContains(t, rendered, "\x1b[") +} + +func TestRenderVariationDiffUsesSideBySideOutputForWideTerminal(t *testing.T) { + rendered, err := renderVariationDiff( + json.RawMessage(`{"name":{"before":"Old","after":"New"}}`), + "plaintext", + 120, + variationDiffPresentation{beforeLabel: "Before", afterLabel: "After"}, + ) + + require.NoError(t, err) + 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.NotContains(t, rendered, "╭") + assert.True(t, containsLineWith(rendered, "Before", "After")) + assert.True(t, containsLineWith(rendered, "Old", "New")) +} + +func TestRenderVariationDiffUsesStackedOutputForNarrowTerminal(t *testing.T) { + rendered, err := renderVariationDiff( + json.RawMessage(`{"name":{"before":"Old","after":"New"}}`), + "plaintext", + 80, + variationDiffPresentation{beforeLabel: "Before", afterLabel: "After"}, + ) + + require.NoError(t, err) + assert.Contains(t, rendered, "Before") + assert.Contains(t, rendered, "After") + assert.False(t, containsLineWith(rendered, "Before", "After")) + assert.False(t, containsLineWith(rendered, "Old", "New")) +} + +func TestRenderVariationDiffWrapsWideColumnsWithoutLosingContent(t *testing.T) { + rendered, err := renderVariationDiff( + json.RawMessage(`{ + "instructions": { + "before": "A long instruction value that ends with oldtailmarker", + "after": "A long instruction value that ends with newtailmarker" + } + }`), + "plaintext", + 100, + variationDiffPresentation{beforeLabel: "Before", afterLabel: "After"}, + ) + + require.NoError(t, err) + assert.Contains(t, rendered, "oldtailmarker") + assert.Contains(t, rendered, "newtailmarker") +} + +func TestRenderVariationDiffUsesMarkdownFallback(t *testing.T) { + rendered, err := renderVariationDiff( + json.RawMessage(`{"name":{"before":"Old","after":"New"}}`), + "markdown", + 120, + variationDiffPresentation{beforeLabel: "Before", afterLabel: "After"}, + ) + + require.NoError(t, err) + assert.Contains(t, rendered, "#### name (changed)") + assert.Contains(t, rendered, "```diff") + assert.Contains(t, rendered, `-"Old"`) + assert.Contains(t, rendered, `+"New"`) + assert.NotContains(t, rendered, "╭") + assert.NotContains(t, rendered, "\x1b[") +} + +func TestRenderVariationDiffCollapsesWholeVariationChanges(t *testing.T) { + for _, test := range []struct { + name string + payload json.RawMessage + expectedKind string + unexpectedKey string + }{ + { + name: "added", + payload: json.RawMessage(`{ + "key": {"after": "new"}, + "mode": {"after": "completion"}, + "name": {"after": "New"} + }`), + expectedKind: "variation (added)", + unexpectedKey: "key (added)", + }, + { + name: "removed", + payload: json.RawMessage(`{ + "key": {"before": "old"}, + "mode": {"before": "completion"}, + "name": {"before": "Old"} + }`), + expectedKind: "variation (removed)", + unexpectedKey: "key (removed)", + }, + } { + t.Run(test.name, func(t *testing.T) { + rendered, err := renderVariationDiff( + test.payload, + "plaintext", + 0, + variationDiffPresentation{ + beforeLabel: "Before", + afterLabel: "After", + }, + ) + + require.NoError(t, err) + assert.Contains(t, rendered, test.expectedKind) + assert.NotContains(t, rendered, test.unexpectedKey) + assert.Equal(t, 1, strings.Count(rendered, "--- Before")) + assert.Equal(t, 1, strings.Count(rendered, "+++ After")) + assert.Contains(t, rendered, `"key":`) + assert.Contains(t, rendered, `"mode":`) + assert.Contains(t, rendered, `"name":`) + }) + } +} + +func TestChangedPartsHighlightsOnlyChangedRunes(t *testing.T) { + prefix, removed, added, suffix := changedParts( + ` "temperature": 0.2`, + ` "temperature": 0.3`, + ) + + assert.Equal(t, ` "temperature": 0.`, prefix) + assert.Equal(t, "2", removed) + assert.Equal(t, "3", added) + assert.Empty(t, suffix) +} + +func containsLineWith(value string, fragments ...string) bool { + for _, line := range strings.Split(value, "\n") { + matches := true + for _, fragment := range fragments { + if !strings.Contains(line, fragment) { + matches = false + break + } + } + if matches { + return true + } + } + return false +} + +func TestWriteSyncOutputJSONIncludesPartialApplyOutcomes(t *testing.T) { + var output bytes.Buffer + err := writeSyncOutput( + &output, + "json", + nil, + []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", + Outcome: syncapi.ResourceApplyOutcomeFailed, + Error: &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.ResourceApplyOutcomeFailed, + decoded.Applies[0].Resources[0].Outcome, + ) +} + +func TestWriteSyncOutputIncludesPulledResources(t *testing.T) { + pulls := []serverPull{{ + ProjectKey: "project", + LookupKey: "support/default", + Path: "project/configs/support/default.prompt.md", + }} + + t.Run("plaintext", func(t *testing.T) { + var output bytes.Buffer + err := writeSyncOutput(&output, "plaintext", pulls, nil, nil) + + require.NoError(t, err) + assert.Contains(t, output.String(), "Pulled server changes:") + assert.Contains( + t, + output.String(), + "project/support/default -> .launchdarkly/project/configs/support/default.prompt.md", + ) + }) + + t.Run("json", func(t *testing.T) { + var output bytes.Buffer + err := writeSyncOutput(&output, "json", pulls, nil, nil) + + require.NoError(t, err) + var decoded syncOutput + require.NoError(t, json.Unmarshal(output.Bytes(), &decoded)) + require.Len(t, decoded.Pulls, 1) + assert.Equal(t, "project", decoded.Pulls[0].ProjectKey) + assert.Equal(t, "support/default", decoded.Pulls[0].LookupKey) + assert.Equal( + t, + "project/configs/support/default.prompt.md", + decoded.Pulls[0].Path, + ) + }) +} diff --git a/cmd/sync/prompt.go b/cmd/sync/prompt.go index 3b468ac6..1a3dd4d4 100644 --- a/cmd/sync/prompt.go +++ b/cmd/sync/prompt.go @@ -1,17 +1,26 @@ package sync import ( + "bufio" + "encoding/json" + "errors" "fmt" + "io" "os" + "reflect" + "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" "github.com/launchdarkly/ldcli/cmd/validators" "github.com/launchdarkly/ldcli/internal/output" "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" syncapi "github.com/launchdarkly/ldcli/internal/sync/api" syncbootstrap "github.com/launchdarkly/ldcli/internal/sync/bootstrap" synclocal "github.com/launchdarkly/ldcli/internal/sync/local" @@ -20,7 +29,9 @@ import ( const ( addFlag = "add" + applyFlag = "apply" dryRunFlag = "dry-run" + yesFlag = "yes" ) type bootstrapRunner func(syncbootstrap.Options) error @@ -36,7 +47,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 @@ -57,6 +68,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 @@ -85,8 +111,42 @@ func runPrompt( if err != nil { return err } + 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, + ) + if err != nil { + return output.NewCmdOutputError( + err, + cliflags.GetOutputKind(cmd), + ) + } + return writeSyncOutput( + cmd.OutOrStdout(), + cliflags.GetOutputKind(cmd), + nil, + nil, + []syncapi.ProjectApply{result}, + ) + } if !storeExists || add { err := bootstrap(syncbootstrap.Options{ Catalog: syncapi.NewCatalogClient( @@ -110,26 +170,780 @@ func runPrompt( return nil } - localResources, err := synclocal.Compile(os.DirFS(workspace.Root)) - if err != nil { - return err + return runWorkspaceSync( + cmd, + client, + workspace, + store, + accessToken, + baseURI, + dryRun, + yes, + ) + } +} + +func runWorkspaceSync( + cmd *cobra.Command, + client resources.Client, + workspace syncsource.Workspace, + store synclocal.Store, + accessToken string, + baseURI string, + dryRun bool, + yes bool, +) error { + projectKeys, err := store.ProjectKeys() + if err != nil { + return err + } + localResources, err := synclocal.Compile(os.DirFS(workspace.Root)) + if err != nil { + return err + } + + outputKind := cliflags.GetOutputKind(cmd) + syncClient := syncapi.NewClient(client) + + // Preview before changing either side so confirmation covers the exact + // resources and diffs the user reviewed. + reviewedPlans, err := syncClient.Plan( + accessToken, + baseURI, + workspace.Source, + true, + projectKeys, + localResources, + ) + if err != nil { + return output.NewCmdOutputError(err, outputKind) + } + if err := planResourceErrors(reviewedPlans); err != nil { + return output.NewCmdOutputError(err, outputKind) + } + if dryRun { + return writePlanOutput(cmd.OutOrStdout(), outputKind, reviewedPlans) + } + if len(reviewedPlans) == 0 { + return writeSyncOutput(cmd.OutOrStdout(), outputKind, nil, nil, nil) + } + + shouldContinue, err := reviewPlans(cmd, outputKind, reviewedPlans, yes) + if err != nil || !shouldContinue { + return err + } + + pulled, err := pullServerVariations( + syncapi.NewCatalogClient(client, accessToken, baseURI), + store, + reviewedPlans, + ) + if err != nil { + return output.NewCmdOutputError(err, outputKind) + } + + // Pulling from LaunchDarkly changes local files. Compile that resulting + // state before creating the durable plan that will actually be applied. + localResources, err = compileExistingStore(store, workspace.Root) + if err != nil { + _ = writeSyncOutput(cmd.OutOrStdout(), outputKind, pulled, nil, nil) + return err + } + + durablePlans, err := syncClient.Plan( + accessToken, + baseURI, + workspace.Source, + false, + projectKeys, + localResources, + ) + if err != nil { + _ = writeSyncOutput(cmd.OutOrStdout(), outputKind, pulled, nil, nil) + return output.NewCmdOutputError(err, outputKind) + } + if err := validateReplannedPlans(reviewedPlans, durablePlans, pulled); err != nil { + _ = writeSyncOutput(cmd.OutOrStdout(), outputKind, pulled, durablePlans, nil) + return err + } + + applies, err := applyPlans( + syncClient, + accessToken, + baseURI, + durablePlans, + ) + if err != nil { + _ = writeCompletedSyncOutput( + cmd.OutOrStdout(), + outputKind, + pulled, + durablePlans, + applies, + ) + return output.NewCmdOutputError(err, outputKind) + } + if err := store.RemoveEmptyDirectories(); err != nil { + return err + } + + return writeCompletedSyncOutput( + cmd.OutOrStdout(), + outputKind, + pulled, + durablePlans, + applies, + ) +} + +func reviewPlans( + cmd *cobra.Command, + outputKind string, + plans []syncapi.ProjectPlan, + yes bool, +) (bool, error) { + confirmationOutput := cmd.ErrOrStderr() + if err := writePlanReview( + confirmationOutput, + "plaintext", + plans, + terminalWidth(confirmationOutput), + ); err != nil { + return false, err + } + if err := validatePlansForSync(plans); err != nil { + return false, err + } + if !plansRequireApply(plans) { + if outputKind == "plaintext" || outputKind == "" { + return false, nil } + return false, writePlanOutput(cmd.OutOrStdout(), outputKind, plans) + } + if yes || !plansNeedConfirmation(plans) { + return true, nil + } - plans, err := syncapi.NewClient(client).Plan( + confirmed, err := confirmApply( + cmd.InOrStdin(), + confirmationOutput, + terminalStreams, + ) + if err != nil { + return false, err + } + if !confirmed { + _, _ = fmt.Fprintln(confirmationOutput, "Sync canceled.") + } + return confirmed, nil +} + +func compileExistingStore( + store synclocal.Store, + workspaceRoot string, +) ([]syncdomain.SyncedResource, error) { + exists, err := store.Exists() + if err != nil || !exists { + return nil, err + } + return synclocal.Compile(os.DirFS(workspaceRoot)) +} + +func applyPlans( + client syncapi.Client, + accessToken string, + baseURI string, + plans []syncapi.ProjectPlan, +) ([]syncapi.ProjectApply, error) { + applies := make([]syncapi.ProjectApply, 0, len(plans)) + for _, plan := range plans { + result, err := client.Apply( accessToken, baseURI, - workspace.Source, - dryRun, - localResources, + plan.ProjectKey, + plan.PlanID, ) if err != nil { - return output.NewCmdOutputError(err, cliflags.GetOutputKind(cmd)) + return applies, err } + applies = append(applies, result) + } + return applies, nil +} - return writePlanOutput( - cmd.OutOrStdout(), - cliflags.GetOutputKind(cmd), - plans, +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: + return fmt.Errorf( + "cannot apply conflicted resource %s/%s", + plan.ProjectKey, + resource.LookupKey, + ) + case resource.SyncDirection == syncapi.SyncDirectionServerCanonical && + resource.Status != syncapi.ResourceStatusInSync: + return fmt.Errorf( + "cannot apply server-canonical resource %s/%s", + plan.ProjectKey, + resource.LookupKey, + ) + } + } + } + return nil +} + +func planResourceErrors(plans []syncapi.ProjectPlan) error { + var resourceErrors []error + for _, plan := range plans { + for _, resource := range plan.Resources { + if resource.Error == nil { + continue + } + resourceErrors = append(resourceErrors, fmt.Errorf( + "- %s/%s: %s: %s", + plan.ProjectKey, + resource.LookupKey, + resource.Error.Code, + resource.Error.Message, + )) + } + } + if len(resourceErrors) == 0 { + return nil + } + return fmt.Errorf("cannot sync:\n%w", errors.Join(resourceErrors...)) +} + +func validatePlansForSync(plans []syncapi.ProjectPlan) error { + for _, plan := range plans { + for _, resource := range plan.Resources { + switch { + case resource.Error != nil: + return fmt.Errorf( + "cannot sync %s/%s: %s", + plan.ProjectKey, + resource.LookupKey, + resource.Error.Message, + ) + case isServerPull(resource): + continue + case resource.Status == syncapi.ResourceStatusConflict: + return fmt.Errorf( + "cannot sync conflicted resource %s/%s", + plan.ProjectKey, + resource.LookupKey, + ) + case resource.SyncDirection == syncapi.SyncDirectionServerCanonical && + resource.Status != syncapi.ResourceStatusInSync: + return fmt.Errorf( + "cannot apply server-canonical resource %s/%s", + plan.ProjectKey, + resource.LookupKey, + ) + } + } + } + return nil +} + +func plansRequireApply(plans []syncapi.ProjectPlan) bool { + for _, plan := range plans { + for _, resource := range plan.Resources { + if resource.Status != syncapi.ResourceStatusInSync || + resource.ManifestUpdateRequired { + return true + } + } + } + return false +} + +func plansNeedConfirmation(plans []syncapi.ProjectPlan) bool { + for _, plan := range plans { + for _, resource := range plan.Resources { + if resource.Status != syncapi.ResourceStatusInSync { + return true + } + } + } + return false +} + +type serverPull struct { + ProjectKey string + LookupKey string + ConfigKey string + VariationKey string + Path string + Action serverPullAction +} + +type serverPullAction int + +const ( + replaceLocalFile serverPullAction = iota + restoreLocalFile + deleteLocalFile +) + +func isServerPull(resource syncapi.PlannedResource) bool { + if resource.Error != nil || + resource.ResourceKind != syncdomain.KindVariation || + resource.Status == syncapi.ResourceStatusInSync { + return false + } + + serverChanged := resource.Status == syncapi.ResourceStatusServerChanged + serverCanUpdateLocal := resource.SyncDirection == syncapi.SyncDirectionBoth || + resource.SyncDirection == syncapi.SyncDirectionServerCanonical + restoreServerCanonicalFile := resource.LocalDeleted && + resource.SyncDirection == syncapi.SyncDirectionServerCanonical + + return (serverChanged && serverCanUpdateLocal) || restoreServerCanonicalFile +} + +func pullServerVariations( + catalog syncapi.CatalogClient, + store synclocal.Store, + plans []syncapi.ProjectPlan, +) ([]serverPull, error) { + pulls, err := collectServerPulls(plans) + if err != nil || len(pulls) == 0 { + return pulls, err + } + + replacements, additions, deletions, err := prepareServerPulls(catalog, pulls) + if err != nil { + return nil, err + } + + replacedPaths, err := store.ReplaceVariations(replacementInputs(replacements)) + if err != nil { + return nil, err + } + addedPaths, err := store.Add(additionInputs(additions)) + if err != nil { + return nil, err + } + deletedPaths, err := store.DeleteVariations(deletionInputs(deletions)) + if err != nil { + return nil, err + } + + for index, path := range replacedPaths { + pulls[replacements[index].pullIndex].Path = path + } + for index, path := range addedPaths { + pulls[additions[index].pullIndex].Path = path + } + for index, path := range deletedPaths { + pulls[deletions[index].pullIndex].Path = path + } + + return pulls, nil +} + +func collectServerPulls(plans []syncapi.ProjectPlan) ([]serverPull, error) { + var pulls []serverPull + for _, plan := range plans { + for _, resource := range plan.Resources { + if !isServerPull(resource) { + continue + } + configKey, variationKey, ok := strings.Cut(resource.LookupKey, "/") + if !ok || + configKey == "" || + variationKey == "" || + strings.Contains(variationKey, "/") { + return nil, fmt.Errorf( + "invalid variation lookup key %q", + resource.LookupKey, + ) + } + pulls = append(pulls, serverPull{ + ProjectKey: plan.ProjectKey, + LookupKey: resource.LookupKey, + ConfigKey: configKey, + VariationKey: variationKey, + Action: pullAction(resource), + }) + } + } + return pulls, nil +} + +func pullAction(resource syncapi.PlannedResource) serverPullAction { + switch { + case resource.ServerDeleted: + return deleteLocalFile + case resource.LocalDeleted: + return restoreLocalFile + default: + return replaceLocalFile + } +} + +type configRef struct { + projectKey string + configKey string +} + +type preparedReplacement struct { + pullIndex int + input synclocal.VariationReplacement +} + +type preparedAddition struct { + pullIndex int + input synclocal.VariationFile +} + +type preparedDeletion struct { + pullIndex int + input synclocal.VariationDeletion +} + +func prepareServerPulls( + catalog syncapi.CatalogClient, + pulls []serverPull, +) ([]preparedReplacement, []preparedAddition, []preparedDeletion, error) { + configs := make(map[configRef]syncapi.Config) + var replacements []preparedReplacement + var additions []preparedAddition + var deletions []preparedDeletion + + for index, pull := range pulls { + if pull.Action == deleteLocalFile { + deletions = append(deletions, preparedDeletion{ + pullIndex: index, + input: synclocal.VariationDeletion{ + ProjectKey: pull.ProjectKey, + ConfigKey: pull.ConfigKey, + VariationKey: pull.VariationKey, + }, + }) + continue + } + + variation, err := serverVariation(catalog, configs, pull) + if err != nil { + return nil, nil, nil, err + } + + if pull.Action == restoreLocalFile { + additions = append(additions, preparedAddition{ + pullIndex: index, + input: synclocal.VariationFile{ + ProjectKey: pull.ProjectKey, + ConfigKey: pull.ConfigKey, + Upsert: false, + Variation: variation, + }, + }) + } else { + replacements = append(replacements, preparedReplacement{ + pullIndex: index, + input: synclocal.VariationReplacement{ + ProjectKey: pull.ProjectKey, + ConfigKey: pull.ConfigKey, + Variation: variation, + }, + }) + } + } + + return replacements, additions, deletions, nil +} + +func serverVariation( + catalog syncapi.CatalogClient, + configs map[configRef]syncapi.Config, + pull serverPull, +) (syncdomain.Variation, error) { + ref := configRef{ + projectKey: pull.ProjectKey, + configKey: pull.ConfigKey, + } + + config, exists := configs[ref] + if !exists { + var err error + config, err = catalog.Config(ref.projectKey, ref.configKey) + if err != nil { + return syncdomain.Variation{}, err + } + configs[ref] = config + } + + for _, variation := range config.Variations { + if variation.Key == pull.VariationKey { + return variation, nil + } + } + + return syncdomain.Variation{}, fmt.Errorf( + "variation %q was not found in AI Config %q", + pull.VariationKey, + pull.ConfigKey, + ) +} + +func replacementInputs(prepared []preparedReplacement) []synclocal.VariationReplacement { + inputs := make([]synclocal.VariationReplacement, len(prepared)) + for index, replacement := range prepared { + inputs[index] = replacement.input + } + return inputs +} + +func additionInputs(prepared []preparedAddition) []synclocal.VariationFile { + inputs := make([]synclocal.VariationFile, len(prepared)) + for index, addition := range prepared { + inputs[index] = addition.input + } + return inputs +} + +func deletionInputs(prepared []preparedDeletion) []synclocal.VariationDeletion { + inputs := make([]synclocal.VariationDeletion, len(prepared)) + for index, deletion := range prepared { + inputs[index] = deletion.input + } + return inputs +} + +func validateReplannedPlans( + reviewed []syncapi.ProjectPlan, + durable []syncapi.ProjectPlan, + pulled []serverPull, +) error { + if len(reviewed) != len(durable) { + return syncStateChangedError() + } + + pulledResources := make(map[projectResourceKey]struct{}, len(pulled)) + for _, resource := range pulled { + pulledResources[projectResourceKey{ + projectKey: resource.ProjectKey, + lookupKey: resource.LookupKey, + }] = struct{}{} + } + + durableByProject := make(map[string]syncapi.ProjectPlan, len(durable)) + for _, plan := range durable { + durableByProject[plan.ProjectKey] = plan + } + + for _, reviewedPlan := range reviewed { + durablePlan, exists := durableByProject[reviewedPlan.ProjectKey] + if !exists || len(reviewedPlan.Resources) != len(durablePlan.Resources) { + return syncStateChangedError() + } + + durableByResource := make( + map[plannedResourceKey]syncapi.PlannedResource, + len(durablePlan.Resources), + ) + for _, resource := range durablePlan.Resources { + durableByResource[plannedResourceKey{ + kind: resource.ResourceKind, + lookupKey: resource.LookupKey, + }] = resource + } + + for _, reviewedResource := range reviewedPlan.Resources { + key := plannedResourceKey{ + kind: reviewedResource.ResourceKind, + lookupKey: reviewedResource.LookupKey, + } + durableResource, exists := durableByResource[key] + if !exists { + return syncStateChangedError() + } + + _, wasPulled := pulledResources[projectResourceKey{ + projectKey: reviewedPlan.ProjectKey, + lookupKey: reviewedResource.LookupKey, + }] + if err := validateReplannedResource( + reviewedPlan.ProjectKey, + reviewedResource, + durableResource, + wasPulled, + ); err != nil { + return err + } + } + } + + return validatePlansForApply(durable) +} + +type projectResourceKey struct { + projectKey string + lookupKey string +} + +type plannedResourceKey struct { + kind syncdomain.Kind + lookupKey string +} + +func validateReplannedResource( + projectKey string, + reviewed syncapi.PlannedResource, + durable syncapi.PlannedResource, + wasPulled bool, +) error { + if reviewed.SyncDirection != durable.SyncDirection { + return syncStateChangedError() + } + if wasPulled { + return validatePulledResource(projectKey, reviewed, durable) + } + + unchanged := reviewed.Status == durable.Status && + reviewed.ManifestUpdateRequired == durable.ManifestUpdateRequired && + reviewed.LocalDeleted == durable.LocalDeleted && + reviewed.ServerDeleted == durable.ServerDeleted && + reflect.DeepEqual(reviewed.Error, durable.Error) && + equalJSON(reviewed.Diff, durable.Diff) + if unchanged { + return nil + } + + return fmt.Errorf( + "sync state changed for %s/%s after review; run sync again", + projectKey, + reviewed.LookupKey, + ) +} + +func validatePulledResource( + projectKey string, + reviewed syncapi.PlannedResource, + durable syncapi.PlannedResource, +) error { + if durable.Error != nil || durable.Status != syncapi.ResourceStatusInSync { + return fmt.Errorf( + "server state changed while pulling %s/%s; run sync again", + projectKey, + reviewed.LookupKey, + ) + } + if reviewed.ServerDeleted && + (!durable.LocalDeleted || !durable.ServerDeleted) { + return fmt.Errorf( + "server state changed while deleting %s/%s; run sync again", + projectKey, + reviewed.LookupKey, ) } + return nil +} + +func syncStateChangedError() error { + return fmt.Errorf("sync state changed after review; run sync again") +} + +func equalJSON(left, right json.RawMessage) bool { + if len(left) == 0 || len(right) == 0 { + return len(left) == len(right) + } + + var leftValue any + var rightValue any + return json.Unmarshal(left, &leftValue) == nil && + json.Unmarshal(right, &rightValue) == nil && + reflect.DeepEqual(leftValue, rightValue) +} + +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, "\nSync these changes? [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, + pulls []serverPull, + plans []syncapi.ProjectPlan, + applies []syncapi.ProjectApply, +) error { + if outputKind == "json" { + return writeSyncOutput(out, outputKind, pulls, plans, applies) + } + return writeSyncOutput(out, outputKind, pulls, nil, applies) } diff --git a/cmd/sync/prompt_test.go b/cmd/sync/prompt_test.go index 43677e62..30856717 100644 --- a/cmd/sync/prompt_test.go +++ b/cmd/sync/prompt_test.go @@ -148,10 +148,522 @@ func TestPromptPlansWithoutDryRunByDefault(t *testing.T) { t.Chdir(repository) t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + client := &recordingClient{ + Responses: [][]byte{ + []byte(`{"resources":[{ + "resourceKind":"variation", + "lookupKey":"support/default", + "status":"in_sync", + "syncDirection":"both", + "manifestUpdateRequired":true + }]}`), + []byte(`{ + "planId": "617c83f1-cd9a-4865-8f37-bb11f88e2147", + "expiresAt": "2026-12-14T12:00:00Z", + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "in_sync", + "syncDirection": "both", + "manifestUpdateRequired": true + }] + }`), + []byte(`{ + "planId": "617c83f1-cd9a-4865-8f37-bb11f88e2147", + "status": "applied", + "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, 3) + + var body struct { + DryRun bool `json:"dryRun"` + } + require.NoError(t, json.Unmarshal(client.Requests[0].Body, &body)) + assert.True(t, body.DryRun) + require.NoError(t, json.Unmarshal(client.Requests[1].Body, &body)) + assert.False(t, body.DryRun) + assert.Equal( + t, + "https://example.com/api/v2/projects/project/ai-configs/sync/apply", + client.Requests[2].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 TestPromptPullsServerChangedVariationThenReplansAndApplies(t *testing.T) { + repository := initRepository(t) + writePrompt(t, repository, "project", "support", "default", true) + writePrompt(t, repository, "project", "support", "local", true) + t.Chdir(repository) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + planID := "617c83f1-cd9a-4865-8f37-bb11f88e2147" + client := &recordingClient{ + Responses: [][]byte{ + []byte(`{ + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "server_changed", + "syncDirection": "both", + "diff": {"name": {"before": "From server", "after": "Default"}} + }, { + "resourceKind": "variation", + "lookupKey": "support/local", + "status": "local_changed", + "syncDirection": "both", + "diff": {"name": {"before": "Old local", "after": "Default"}} + }] + }`), + []byte(`{ + "key": "support", + "name": "Support", + "mode": "completion", + "variations": [{ + "key": "default", + "name": "From server", + "modelConfigKey": "claude", + "modelConfigVersion": 3, + "messages": [{"role": "system", "content": "Use server state."}] + }, { + "key": "local", + "name": "Old local", + "messages": [{"role": "system", "content": "Old state."}] + }] + }`), + []byte(`{ + "planId": "` + planID + `", + "expiresAt": "2026-12-14T12:00:00Z", + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "in_sync", + "syncDirection": "both" + }, { + "resourceKind": "variation", + "lookupKey": "support/local", + "status": "local_changed", + "syncDirection": "both", + "diff": {"name": {"before": "Old local", "after": "Default"}} + }] + }`), + []byte(`{ + "planId": "` + planID + `", + "status": "applied", + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "in_sync", + "syncDirection": "both", + "outcome": "applied" + }, { + "resourceKind": "variation", + "lookupKey": "support/local", + "status": "local_changed", + "syncDirection": "both", + "outcome": "applied" + }] + }`), + }, + } + + 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, 4) + assert.Contains(t, client.Requests[0].Path, "/sync/plan") + assert.Equal( + t, + "https://example.com/api/v2/projects/project/ai-configs/support", + client.Requests[1].Path, + ) + assert.Contains(t, client.Requests[2].Path, "/sync/plan") + assert.Contains(t, client.Requests[3].Path, "/sync/apply") + + var durableRequest struct { + DryRun bool `json:"dryRun"` + Resources []struct { + Payload json.RawMessage `json:"payload"` + } `json:"resources"` + } + require.NoError(t, json.Unmarshal(client.Requests[2].Body, &durableRequest)) + assert.False(t, durableRequest.DryRun) + require.Len(t, durableRequest.Resources, 2) + assert.JSONEq(t, `{ + "mode": "completion", + "key": "default", + "name": "From server", + "modelConfigKey": "claude", + "modelConfigVersion": 3, + "messages": [{"role": "system", "content": "Use server state."}] + }`, string(durableRequest.Resources[0].Payload)) + + promptPath := filepath.Join( + repository, + ".launchdarkly", + "project", + "configs", + "support", + "default.prompt.md", + ) + content, err := os.ReadFile(promptPath) + require.NoError(t, err) + assert.Contains(t, string(content), "upsert: true") + assert.Contains(t, string(content), "name: From server") + assert.Contains(t, string(content), "Use server state.") + + var output struct { + Pulls []struct { + ProjectKey string `json:"projectKey"` + LookupKey string `json:"lookupKey"` + Path string `json:"path"` + } `json:"pulls"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &output)) + require.Len(t, output.Pulls, 1) + assert.Equal(t, "project", output.Pulls[0].ProjectKey) + assert.Equal(t, "support/default", output.Pulls[0].LookupKey) + assert.Equal( + t, + "project/configs/support/default.prompt.md", + output.Pulls[0].Path, + ) +} + +func TestPromptDeletesLocalFileForServerDeletionThenRemovesManifest(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(`{ + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "server_changed", + "syncDirection": "both", + "serverDeleted": true, + "diff": {"name": {"after": "Default"}} + }] + }`), + []byte(`{ + "planId": "` + planID + `", + "expiresAt": "2026-12-14T12:00:00Z", + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "in_sync", + "syncDirection": "both", + "manifestUpdateRequired": true, + "localDeleted": true, + "serverDeleted": true + }] + }`), + []byte(`{ + "planId": "` + planID + `", + "status": "applied", + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "in_sync", + "syncDirection": "both", + "outcome": "applied" + }] + }`), + }, + } + + 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, 3) + assert.Contains(t, client.Requests[0].Path, "/sync/plan") + assert.Contains(t, client.Requests[1].Path, "/sync/plan") + assert.Contains(t, client.Requests[2].Path, "/sync/apply") + _, statErr := os.Stat(filepath.Join(repository, ".launchdarkly")) + require.ErrorIs(t, statErr, os.ErrNotExist) + + var output struct { + Pulls []struct { + Deleted bool `json:"deleted"` + } `json:"pulls"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &output)) + require.Len(t, output.Pulls, 1) + assert.True(t, output.Pulls[0].Deleted) +} + +func TestPromptDeletesServerVariationForLocalFileDeletion(t *testing.T) { + repository := initRepository(t) + writePrompt(t, repository, "project", "support", "default", true) + require.NoError(t, os.Remove(filepath.Join( + repository, + ".launchdarkly", + "project", + "configs", + "support", + "default.prompt.md", + ))) + t.Chdir(repository) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + planID := "617c83f1-cd9a-4865-8f37-bb11f88e2147" + deletedPlan := `{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "local_changed", + "syncDirection": "both", + "localDeleted": true, + "diff": {"name": {"before": "Default"}} + }` + client := &recordingClient{ + Responses: [][]byte{ + []byte(`{"resources": [` + deletedPlan + `]}`), + []byte(`{ + "planId": "` + planID + `", + "expiresAt": "2026-12-14T12:00:00Z", + "resources": [` + deletedPlan + `] + }`), + []byte(`{ + "planId": "` + planID + `", + "status": "applied", + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "local_changed", + "syncDirection": "both", + "outcome": "applied" + }] + }`), + }, + } + + _, _, 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, 3) + assert.Contains(t, client.Requests[0].Path, "/sync/plan") + assert.Contains(t, client.Requests[1].Path, "/sync/plan") + assert.Contains(t, client.Requests[2].Path, "/sync/apply") + assert.Contains(t, string(client.Requests[0].Body), `"resources": []`) + _, statErr := os.Stat(filepath.Join(repository, ".launchdarkly")) + require.ErrorIs(t, statErr, os.ErrNotExist) +} + +func TestPromptRestoresServerCanonicalFileDeletedLocally(t *testing.T) { + repository := initRepository(t) + writePrompt(t, repository, "project", "support", "default", true) + promptPath := filepath.Join( + repository, + ".launchdarkly", + "project", + "configs", + "support", + "default.prompt.md", + ) + require.NoError(t, os.Remove(promptPath)) + t.Chdir(repository) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + planID := "617c83f1-cd9a-4865-8f37-bb11f88e2147" + client := &recordingClient{ + Responses: [][]byte{ + []byte(`{"resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "local_changed", + "syncDirection": "server_canonical", + "localDeleted": true, + "diff": {"name": {"before": "From server"}} + }]}`), + []byte(`{ + "key": "support", + "name": "Support", + "mode": "completion", + "variations": [{ + "key": "default", + "name": "From server", + "messages": [{"role": "system", "content": "Restore me."}] + }] + }`), + []byte(`{ + "planId": "` + planID + `", + "expiresAt": "2026-12-14T12:00:00Z", + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "in_sync", + "syncDirection": "server_canonical" + }] + }`), + []byte(`{ + "planId": "` + planID + `", + "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) + content, readErr := os.ReadFile(promptPath) + require.NoError(t, readErr) + assert.Contains(t, string(content), "upsert: false") + assert.Contains(t, string(content), "name: From server") + assert.Contains(t, string(content), "Restore me.") +} + +func TestPromptDoesNotApplyWhenServerChangesDuringPull(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": "server_changed", + "syncDirection": "both" + }] + }`), + []byte(`{ + "key": "support", + "mode": "completion", + "variations": [{ + "key": "default", + "name": "Fetched server value", + "messages": [{"role": "system", "content": "Fetched state."}] + }] + }`), + []byte(`{ + "planId": "617c83f1-cd9a-4865-8f37-bb11f88e2147", + "expiresAt": "2026-12-14T12:00:00Z", + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "server_changed", + "syncDirection": "both" + }] + }`), + }, + } + + 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.ErrorContains(t, err, "server state changed while pulling") + require.Len(t, client.Requests, 3) + assert.NotContains(t, client.Requests[2].Path, "/sync/apply") + + var output struct { + Pulls []map[string]any `json:"pulls"` + Plans []map[string]any `json:"plans"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &output)) + require.Len(t, output.Pulls, 1) + require.Len(t, output.Plans, 1) +} + +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 +674,359 @@ 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 := initRepository(t) + 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(`{ + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "local_changed", + "syncDirection": "both" + }] + }`)}, + } + + _, _, 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 TestPromptSkipsPlanAndApplyWhenEverythingIsTrackedAndInSync(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": "in_sync", + "syncDirection": "both", + "manifestUpdateRequired": false + }] + }`)}, + } + + _, stderr, 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, 1) + assert.NotContains(t, stderr, "Sync these changes?") + assert.NotContains(t, stderr, "Apply:") +} + +func TestPromptAppliesWithoutConfirmationWhenManifestUpdateIsRequired(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(`{ + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "in_sync", + "syncDirection": "both", + "manifestUpdateRequired": true + }] + }`), + []byte(`{ + "planId": "` + planID + `", + "expiresAt": "2026-12-14T12:00:00Z", + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "in_sync", + "syncDirection": "both", + "manifestUpdateRequired": true + }] + }`), + []byte(`{ + "planId": "` + planID + `", + "status": "applied", + "resources": [{ + "resourceKind": "variation", + "lookupKey": "support/default", + "status": "in_sync", + "syncDirection": "both", + "outcome": "applied" + }] + }`), + }, + } + + _, stderr, 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, 3) + assert.NotContains(t, stderr, "Sync these changes?") + assert.Contains(t, client.Requests[2].Path, "/sync/apply") +} + +func TestPromptReportsAllResourceErrorsWithoutDisplayingPlan(t *testing.T) { + repository := initRepository(t) + writePrompt(t, repository, "project", "support", "first", false) + writePrompt(t, repository, "project", "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": "both", + "error": { + "code": "resource_not_found", + "message": "resource does not exist and upsert is false" + } + }, { + "resourceKind": "variation", + "lookupKey": "support/second", + "status": "local_changed", + "syncDirection": "both", + "error": { + "code": "invalid_resource", + "message": "resource is invalid" + } + }] + }`)}, + } + + stdout, stderr, 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, "cannot sync:") + require.ErrorContains(t, err, "project/support/first: resource_not_found") + require.ErrorContains(t, err, "project/support/second: invalid_resource") + require.Len(t, client.Requests, 1) + assert.Empty(t, stdout) + assert.NotContains(t, stderr, "Project:") + assert.NotContains(t, stderr, "Status:") +} + +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(`{ + "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, "cannot sync conflicted resource") + 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(`{"resources":[{"resourceKind":"variation","lookupKey":"support/first","status":"in_sync","syncDirection":"both","manifestUpdateRequired":true}]}`), + []byte(`{"resources":[{"resourceKind":"variation","lookupKey":"support/second","status":"in_sync","syncDirection":"both","manifestUpdateRequired":true}]}`), + []byte(`{"planId":"` + alphaPlanID + `","expiresAt":"2026-12-14T12:00:00Z","resources":[{"resourceKind":"variation","lookupKey":"support/first","status":"in_sync","syncDirection":"both","manifestUpdateRequired":true}]}`), + []byte(`{"planId":"` + zetaPlanID + `","expiresAt":"2026-12-14T12:00:00Z","resources":[{"resourceKind":"variation","lookupKey":"support/second","status":"in_sync","syncDirection":"both","manifestUpdateRequired":true}]}`), + []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, 6) + 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, client.Requests[4].Path, "/projects/alpha/") + assert.Contains(t, client.Requests[5].Path, "/projects/zeta/") + assert.Contains(t, string(client.Requests[4].Body), alphaPlanID) + assert.Contains(t, string(client.Requests[5].Body), zetaPlanID) } func TestPromptPreviewRequiresGitRepository(t *testing.T) { @@ -234,12 +1084,19 @@ func TestPromptPreviewGroupsRequestsByProject(t *testing.T) { 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.NotContains(t, string(stdout), "action=") - assert.Contains(t, string(stdout), "diff=") + assert.Contains(t, string(stdout), "LaunchDarkly changes detected") + assert.Contains(t, string(stdout), "Update the local file from LaunchDarkly") + assert.NotContains(t, string(stdout), "Direction:") + assert.NotContains(t, string(stdout), "server_changed") + assert.Contains(t, string(stdout), "--- LaunchDarkly now") + assert.Contains( + t, + string(stdout), + "+++ LaunchDarkly after sync (from local file)", + ) } -func TestPromptPreviewWithoutResourcesMakesNoRequest(t *testing.T) { +func TestPromptPreviewSendsEmptyProjectInventory(t *testing.T) { repository := initRepository(t) require.NoError(t, os.MkdirAll( filepath.Join(repository, ".launchdarkly", "project"), @@ -248,7 +1105,9 @@ func TestPromptPreviewWithoutResourcesMakesNoRequest(t *testing.T) { t.Chdir(repository) t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - client := &recordingClient{} + client := &recordingClient{ + Responses: [][]byte{[]byte(`{"resources":[]}`)}, + } stdout, _, err := cmd.CallCmdCapturingStderr( t, cmd.APIClients{ResourcesClient: client}, @@ -262,8 +1121,11 @@ func TestPromptPreviewWithoutResourcesMakesNoRequest(t *testing.T) { ) require.NoError(t, err) - assert.JSONEq(t, `[]`, string(stdout)) - assert.Empty(t, client.Requests) + assert.JSONEq(t, `[{"projectKey":"project","resources":[]}]`, string(stdout)) + require.Len(t, client.Requests, 1) + assert.Contains(t, client.Requests[0].Path, "/projects/project/") + assert.Contains(t, string(client.Requests[0].Body), `"fullInventory": true`) + assert.Contains(t, string(client.Requests[0].Body), `"resources": []`) } func initRepository(t *testing.T) string { diff --git a/go.mod b/go.mod index 25483824..580e2437 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pkg/errors v0.9.1 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/samber/lo v1.51.0 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.10 @@ -84,7 +85,6 @@ require ( github.com/oasdiff/yaml3 v0.0.14 // indirect github.com/onsi/gomega v1.27.6 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect diff --git a/internal/sync/api/client.go b/internal/sync/api/client.go index fbddd829..2d87ae8d 100644 --- a/internal/sync/api/client.go +++ b/internal/sync/api/client.go @@ -33,12 +33,15 @@ type ResourceError struct { } type PlannedResource struct { - ResourceKind syncdomain.Kind `json:"resourceKind"` - LookupKey string `json:"lookupKey"` - Status ResourceStatus `json:"status"` - SyncDirection SyncDirection `json:"syncDirection"` - Diff json.RawMessage `json:"diff,omitempty"` - Error *ResourceError `json:"error,omitempty"` + ResourceKind syncdomain.Kind `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Status ResourceStatus `json:"status"` + SyncDirection SyncDirection `json:"syncDirection"` + ManifestUpdateRequired bool `json:"manifestUpdateRequired"` + LocalDeleted bool `json:"localDeleted"` + ServerDeleted bool `json:"serverDeleted"` + Diff json.RawMessage `json:"diff,omitempty"` + Error *ResourceError `json:"error,omitempty"` } type ProjectPlan struct { @@ -48,10 +51,45 @@ type ProjectPlan struct { Resources []PlannedResource `json:"resources"` } +type PlanStatus string + +const ( + PlanStatusApplied PlanStatus = "applied" + PlanStatusFailed PlanStatus = "failed" +) + +type ResourceApplyOutcome string + +const ( + ResourceApplyOutcomeApplied ResourceApplyOutcome = "applied" + ResourceApplyOutcomeFailed ResourceApplyOutcome = "failed" + ResourceApplyOutcomeNotAttempted ResourceApplyOutcome = "not_attempted" +) + +type AppliedResource struct { + ResourceKind syncdomain.Kind `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Outcome ResourceApplyOutcome `json:"outcome"` + Error *ResourceError `json:"error,omitempty"` +} + +type ProjectApply struct { + ProjectKey string `json:"-"` + PlanID string `json:"planId"` + Status PlanStatus `json:"status"` + Error *ResourceError `json:"error,omitempty"` + Resources []AppliedResource `json:"resources"` +} + +type applyRequest struct { + PlanID string `json:"planId"` +} + type planRequest struct { - Source sourceRequest `json:"source"` - DryRun bool `json:"dryRun"` - Resources []resourceInput `json:"resources"` + Source sourceRequest `json:"source"` + DryRun bool `json:"dryRun"` + FullInventory bool `json:"fullInventory"` + Resources []resourceInput `json:"resources"` } type sourceRequest struct { @@ -84,11 +122,14 @@ func (client Client) Plan( baseURI string, source syncdomain.Source, dryRun bool, + inventoryProjectKeys []string, synced []syncdomain.SyncedResource, ) ([]ProjectPlan, error) { plans := make([]ProjectPlan, 0) - for _, project := range groupResourcesByProject(synced) { + // Inventory projects must be planned even when they contain no local + // resources, because an empty inventory can represent local deletions. + for _, project := range groupResourcesByProject(synced, inventoryProjectKeys) { plan, err := client.planProject(accessToken, baseURI, source, dryRun, project) if err != nil { return nil, err @@ -100,6 +141,55 @@ func (client Client) Plan( return plans, nil } +func (client Client) Apply( + accessToken string, + baseURI string, + projectKey string, + planID string, +) (ProjectApply, error) { + body, err := json.MarshalIndent(applyRequest{ + PlanID: planID, + }, "", " ") + 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, @@ -112,8 +202,9 @@ func (client Client) planProject( Type: source.Type(), Identifier: source.Identifier(), }, - DryRun: dryRun, - Resources: make([]resourceInput, 0, len(project.Resources)), + DryRun: dryRun, + FullInventory: true, + Resources: make([]resourceInput, 0, len(project.Resources)), } for _, resource := range project.Resources { @@ -161,8 +252,14 @@ func (client Client) planProject( if err := json.Unmarshal(response, &plan); err != nil { return ProjectPlan{}, fmt.Errorf("decode plan response: %w", err) } - if !dryRun && (plan.PlanID == "" || plan.ExpiresAt == "") { - return ProjectPlan{}, fmt.Errorf("decode plan response: durable plan requires planId and expiresAt") + if !dryRun { + hasPlanID := plan.PlanID != "" + hasExpiration := plan.ExpiresAt != "" + if hasPlanID != hasExpiration || (!hasPlanID && !hasConflict(plan)) { + return ProjectPlan{}, fmt.Errorf( + "decode plan response: durable plan requires planId and expiresAt", + ) + } } plan.ProjectKey = project.ProjectKey @@ -170,10 +267,30 @@ func (client Client) planProject( return plan, nil } -func groupResourcesByProject(synced []syncdomain.SyncedResource) []projectResources { +func hasConflict(plan ProjectPlan) bool { + for _, resource := range plan.Resources { + if resource.Status == ResourceStatusConflict { + return true + } + } + return false +} + +func groupResourcesByProject( + synced []syncdomain.SyncedResource, + inventoryProjectKeys []string, +) []projectResources { var projects []projectResources byProject := make(map[string]int) + for _, projectKey := range inventoryProjectKeys { + if _, exists := byProject[projectKey]; exists { + continue + } + byProject[projectKey] = len(projects) + projects = append(projects, projectResources{ProjectKey: projectKey}) + } + for _, resource := range synced { index, ok := byProject[resource.ProjectKey] if !ok { diff --git a/internal/sync/api/client_test.go b/internal/sync/api/client_test.go index 0ba369eb..51e7ea5f 100644 --- a/internal/sync/api/client_test.go +++ b/internal/sync/api/client_test.go @@ -69,6 +69,9 @@ func TestClientPlan(t *testing.T) { "lookupKey": "config/first", "status": "local_changed", "syncDirection": "code_canonical", + "manifestUpdateRequired": true, + "localDeleted": true, + "serverDeleted": false, "diff": {"name": {"before": "Old", "after": "First"}} }] }`), @@ -90,6 +93,7 @@ func TestClientPlan(t *testing.T) { "https://example.com", source, true, + nil, []syncdomain.SyncedResource{ variationResource("alpha", "config/first", "First", true), variationResource("zeta", "config/second", "Second", false), @@ -116,6 +120,7 @@ func TestClientPlan(t *testing.T) { assert.Equal(t, syncdomain.SourceTypeGit, request.Source.Type) assert.Equal(t, "github.com/launchdarkly/example", request.Source.Identifier) assert.True(t, request.DryRun) + assert.True(t, request.FullInventory) require.Len(t, request.Resources, 1) assert.Equal(t, syncdomain.KindVariation, request.Resources[0].ResourceKind) assert.Equal(t, "config/first", request.Resources[0].LookupKey) @@ -127,6 +132,9 @@ 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.True(t, plans[0].Resources[0].ManifestUpdateRequired) + assert.True(t, plans[0].Resources[0].LocalDeleted) + assert.False(t, plans[0].Resources[0].ServerDeleted) assert.Equal(t, "zeta", plans[1].ProjectKey) assert.Equal(t, ResourceStatusServerChanged, plans[1].Resources[0].Status) } @@ -146,6 +154,7 @@ func TestClientPlanDecodesDurablePlanIdentity(t *testing.T) { "https://example.com", requireSource(t, syncdomain.SourceTypeGit, "github.com/launchdarkly/example"), false, + nil, []syncdomain.SyncedResource{ variationResource("project", "config/first", "First", true), }, @@ -169,9 +178,10 @@ func TestClientPlanReturnsEmptyResultWithoutResources(t *testing.T) { plans, err := client.Plan( "token", "https://example.com", - requireSource(t, syncdomain.SourceTypeGit, "sha256.local"), + requireSource(t, syncdomain.SourceTypeGit, "github.com/launchdarkly/example"), true, nil, + nil, ) require.NoError(t, err) @@ -179,6 +189,31 @@ func TestClientPlanReturnsEmptyResultWithoutResources(t *testing.T) { assert.Empty(t, transport.Requests) } +func TestClientPlanSendsEmptyProjectInventory(t *testing.T) { + transport := &recordingClient{ + Responses: [][]byte{[]byte(`{"resources":[]}`)}, + } + client := NewClient(transport) + + plans, err := client.Plan( + "token", + "https://example.com", + requireSource(t, syncdomain.SourceTypeGit, "github.com/launchdarkly/example"), + true, + []string{"project"}, + nil, + ) + + require.NoError(t, err) + require.Len(t, plans, 1) + assert.Equal(t, "project", plans[0].ProjectKey) + require.Len(t, transport.Requests, 1) + var request planRequest + require.NoError(t, json.Unmarshal(transport.Requests[0].Body, &request)) + assert.True(t, request.FullInventory) + assert.Empty(t, request.Resources) +} + func TestClientPlanRejectsDurableResponseWithoutIdentity(t *testing.T) { client := NewClient(&recordingClient{ Responses: [][]byte{[]byte(`{"resources":[]}`)}, @@ -189,6 +224,7 @@ func TestClientPlanRejectsDurableResponseWithoutIdentity(t *testing.T) { "https://example.com", requireSource(t, syncdomain.SourceTypeGit, "github.com/acme/repo"), false, + nil, []syncdomain.SyncedResource{ variationResource("project", "config/key", "Name", false), }, @@ -197,6 +233,36 @@ func TestClientPlanRejectsDurableResponseWithoutIdentity(t *testing.T) { require.ErrorContains(t, err, "durable plan requires planId and expiresAt") } +func TestClientPlanAcceptsConflictWithoutDurableIdentity(t *testing.T) { + client := NewClient(&recordingClient{ + Responses: [][]byte{[]byte(`{ + "resources": [{ + "resourceKind": "variation", + "lookupKey": "config/key", + "status": "conflict", + "syncDirection": "both" + }] + }`)}, + }) + + plans, err := client.Plan( + "token", + "https://example.com", + requireSource(t, syncdomain.SourceTypeGit, "github.com/acme/repo"), + false, + nil, + []syncdomain.SyncedResource{ + variationResource("project", "config/key", "Name", false), + }, + ) + + require.NoError(t, err) + require.Len(t, plans, 1) + assert.Empty(t, plans[0].PlanID) + assert.Empty(t, plans[0].ExpiresAt) + assert.Equal(t, ResourceStatusConflict, plans[0].Resources[0].Status) +} + func TestClientPlanRejectsUnsupportedResource(t *testing.T) { transport := &recordingClient{} client := NewClient(transport) @@ -206,6 +272,7 @@ func TestClientPlanRejectsUnsupportedResource(t *testing.T) { "https://example.com", requireSource(t, syncdomain.SourceTypeGit, "github.com/acme/repo"), true, + nil, []syncdomain.SyncedResource{{ ProjectKey: "project", Kind: "unknown", @@ -225,6 +292,7 @@ func TestClientPlanReturnsTransportError(t *testing.T) { "https://example.com", requireSource(t, syncdomain.SourceTypeGit, "github.com/acme/repo"), true, + nil, []syncdomain.SyncedResource{variationResource("project", "config/key", "Name", false)}, ) @@ -239,12 +307,88 @@ func TestClientPlanRejectsInvalidResponse(t *testing.T) { "https://example.com", requireSource(t, syncdomain.SourceTypeGit, "github.com/acme/repo"), true, + nil, []syncdomain.SyncedResource{variationResource("project", "config/key", "Name", false)}, ) 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", + "resources": [{ + "resourceKind": "variation", + "lookupKey": "config/first", + "outcome": "applied" + }] + }`)}, + } + client := NewClient(transport) + + result, err := client.Apply( + "token", + "https://example.com", + "project", + "617c83f1-cd9a-4865-8f37-bb11f88e2147", + ) + + 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.JSONEq( + t, + `{"planId":"617c83f1-cd9a-4865-8f37-bb11f88e2147"}`, + string(request.Body), + ) + assert.Equal(t, "project", result.ProjectKey) + assert.Equal(t, PlanStatusApplied, result.Status) + require.Len(t, result.Resources, 1) + assert.Equal(t, ResourceApplyOutcomeApplied, result.Resources[0].Outcome) +} + +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", + ) + + 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", + ) + + require.ErrorContains(t, err, "sync plan has expired") +} + func variationResource( projectKey string, lookupKey string,