diff --git a/internal/commands/exit_codes.go b/internal/commands/exit_codes.go index c2b1d58..d7afea3 100644 --- a/internal/commands/exit_codes.go +++ b/internal/commands/exit_codes.go @@ -36,4 +36,10 @@ const ( // ExitConfigError indicates a configuration error // (missing config file, invalid YAML, no server configured) ExitConfigError = 8 + + // ExitDiffChanges indicates `sc theme diff --exit-code` found differences + // between the local theme and the server. It is NOT an error — the command + // succeeded; this code lets CI distinguish "theme has undeployed drift" + // from a genuine failure (which uses ExitGenericError and friends). + ExitDiffChanges = 9 ) diff --git a/internal/commands/responses.go b/internal/commands/responses.go index 03720fc..d5517c0 100644 --- a/internal/commands/responses.go +++ b/internal/commands/responses.go @@ -1,6 +1,10 @@ package commands -import "time" +import ( + "time" + + "github.com/GetStoreConnect/storeconnect-cli/internal/theme" +) // SuccessResponse is the standard JSON success response format type SuccessResponse struct { @@ -83,3 +87,14 @@ type ThemePublishResponse struct { Status string `json:"status"` Message string `json:"message,omitempty"` } + +// ThemeDiffResponse is the response for the theme diff command: the divergence +// between the local theme and the server, plus rolled-up counts. +type ThemeDiffResponse struct { + ThemeName string `json:"theme_name"` + Templates theme.CategoryDiff `json:"templates"` + Assets theme.CategoryDiff `json:"assets"` + HasChanges bool `json:"has_changes"` + Pushable int `json:"pushable"` + ServerOnly int `json:"server_only"` +} diff --git a/internal/commands/theme_diff.go b/internal/commands/theme_diff.go new file mode 100644 index 0000000..ae4899e --- /dev/null +++ b/internal/commands/theme_diff.go @@ -0,0 +1,162 @@ +package commands + +import ( + "fmt" + "os" + + "github.com/GetStoreConnect/storeconnect-cli/internal/api" + "github.com/GetStoreConnect/storeconnect-cli/internal/config" + "github.com/GetStoreConnect/storeconnect-cli/internal/theme" + "github.com/GetStoreConnect/storeconnect-cli/internal/ui" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +func init() { + themeCmd.AddCommand(themeDiffCmd) + themeDiffCmd.Flags().Bool("exit-code", false, "exit with code 9 when the local theme differs from the server (for CI)") +} + +var themeDiffCmd = &cobra.Command{ + Use: "diff THEME_NAME", + Short: "Show how the local theme differs from the server", + Long: `Compare a local theme against the server's copy without pushing. + +Shows which templates and asset binaries have been added, modified, or only +exist on the server. This is the read-only sibling of 'sc theme push' — it +reports exactly what a push would send. + +With --exit-code the command exits 9 when there are any differences, so CI can +gate on a theme being in sync with what's deployed: + + sc theme diff my-theme --exit-code || echo "theme has undeployed changes"`, + Args: cobra.ExactArgs(1), + RunE: runThemeDiff, +} + +func runThemeDiff(cmd *cobra.Command, args []string) error { + themeName := args[0] + formatter := ui.NewFormatter() + exitCode, _ := cmd.Flags().GetBool("exit-code") + + client, serverAlias, err := getAPIClient(cmd) + if err != nil { + if !jsonOutput { + formatter.Error(fmt.Sprintf("Failed to get API client: %v", err)) + } + return outputError(err) + } + + var spinner *ui.Spinner + if !jsonOutput { + spinner = ui.NewSpinner(fmt.Sprintf("Comparing theme '%s'", themeName)) + spinner.Start() + } + + // Read the local theme (templates) and its asset binaries. + deserializer := theme.NewDeserializer(".") + localTheme, err := deserializer.Deserialize(themeName) + if err != nil { + if spinner != nil { + spinner.Error(fmt.Sprintf("Failed to read theme: %v", err)) + } + return outputError(err) + } + + themeDir := fmt.Sprintf("themes/%s", themeName) + localAssets, err := theme.ReadLocalAssets(themeDir) + if err != nil { + if spinner != nil { + spinner.Error(fmt.Sprintf("Failed to read assets: %v", err)) + } + return outputError(err) + } + + // Resolve the server-side theme id the same way push does. + themeID := localTheme.SCID + if themeID == "" { + if syncState, sErr := config.NewSyncState(themeDir); sErr == nil { + themeID = syncState.ThemeSCID + } + } + if themeID == "" { + if spinner != nil { + spinner.Error("Theme has no SC ID. Pull the theme first or create it on the server.") + } + return fmt.Errorf("theme has no sc_id") + } + + themesService := api.NewThemes(client) + serverTheme, err := themesService.Get(themeID) + if err != nil { + if spinner != nil { + spinner.Error(fmt.Sprintf("Failed to fetch server theme: %v", err)) + } + return outputError(err) + } + + if spinner != nil { + spinner.Stop() + } + + diff := theme.ComputeDiff(localTheme, localAssets, serverTheme) + + if jsonOutput { + if err := outputResponse(ThemeDiffResponse{ + ThemeName: themeName, + Templates: diff.Templates, + Assets: diff.Assets, + HasChanges: diff.HasChanges(), + Pushable: diff.PushableCount(), + ServerOnly: diff.ServerOnlyCount(), + }, nil); err != nil { + return err + } + } else { + renderDiff(formatter, themeName, serverAlias, diff) + } + + if exitCode && diff.HasChanges() { + os.Exit(ExitDiffChanges) + } + + return nil +} + +func renderDiff(formatter *ui.Formatter, themeName, serverAlias string, diff theme.Diff) { + if !diff.HasChanges() { + formatter.Success(fmt.Sprintf("Theme '%s' is in sync with %s", themeName, serverAlias)) + return + } + + formatter.Info(fmt.Sprintf("Diff for theme '%s' against %s:", themeName, serverAlias)) + + renderCategory(formatter, "Templates", diff.Templates) + renderCategory(formatter, "Assets", diff.Assets) + + formatter.Newline() + formatter.Dim(fmt.Sprintf("%d to push, %d only on server", diff.PushableCount(), diff.ServerOnlyCount())) +} + +func renderCategory(formatter *ui.Formatter, label string, c theme.CategoryDiff) { + if len(c.Added)+len(c.Modified)+len(c.Removed) == 0 { + return + } + + formatter.Newline() + formatter.Print(label + ":") + + added := color.New(color.FgGreen).SprintFunc() + modified := color.New(color.FgYellow).SprintFunc() + removed := color.New(color.FgRed).SprintFunc() + + for _, key := range c.Added { + fmt.Printf(" %s %s\n", added("+"), key) + } + for _, key := range c.Modified { + fmt.Printf(" %s %s\n", modified("~"), key) + } + for _, key := range c.Removed { + fmt.Printf(" %s %s\n", removed("-"), key) + } +} diff --git a/internal/theme/diff.go b/internal/theme/diff.go new file mode 100644 index 0000000..e92e5e1 --- /dev/null +++ b/internal/theme/diff.go @@ -0,0 +1,126 @@ +package theme + +import ( + "sort" + + "github.com/GetStoreConnect/storeconnect-cli/internal/api" +) + +// CategoryDiff holds the keys that differ between local and server for one +// category of theme content (templates or assets). Added are present locally +// but not on the server; Removed are present on the server but not locally; +// Modified are present in both but with differing content. All slices are +// sorted for stable output. +type CategoryDiff struct { + Added []string `json:"added"` + Modified []string `json:"modified"` + Removed []string `json:"removed"` +} + +// count returns the total number of differing keys in this category. +func (c CategoryDiff) count() int { + return len(c.Added) + len(c.Modified) + len(c.Removed) +} + +// Diff is the full divergence between a local theme and the server's copy. +type Diff struct { + Templates CategoryDiff `json:"templates"` + Assets CategoryDiff `json:"assets"` +} + +// HasChanges reports whether the local theme differs from the server in any way. +func (d Diff) HasChanges() bool { + return d.Templates.count() > 0 || d.Assets.count() > 0 +} + +// PushableCount is the number of keys that `sc theme push` would send: added +// and modified templates and assets. Removed keys (present on the server but +// not locally) are excluded because push never deletes server content. +func (d Diff) PushableCount() int { + return len(d.Templates.Added) + len(d.Templates.Modified) + + len(d.Assets.Added) + len(d.Assets.Modified) +} + +// ServerOnlyCount is the number of keys present on the server but not locally +// (removed templates and assets) — divergence that push will not reconcile. +func (d Diff) ServerOnlyCount() int { + return len(d.Templates.Removed) + len(d.Assets.Removed) +} + +// ComputeDiff compares a local theme against the server's copy. Templates are +// compared by key, treating a content difference as a modification. Assets are +// compared by key using their SHA-256 content hash, so an unchanged binary is +// never reported even though its bytes are read locally. ComputeDiff is pure so +// the comparison rule is trivially unit-testable. +func ComputeDiff(local *api.Theme, localAssets []LocalAsset, server *api.Theme) Diff { + return Diff{ + Templates: diffTemplates(local, server), + Assets: diffAssets(localAssets, server), + } +} + +func diffTemplates(local, server *api.Theme) CategoryDiff { + localByKey := make(map[string]string, len(local.Templates)) + for _, t := range local.Templates { + localByKey[t.Key] = t.Content + } + serverByKey := make(map[string]string, len(server.Templates)) + for _, t := range server.Templates { + serverByKey[t.Key] = t.Content + } + + var diff CategoryDiff + for key, localContent := range localByKey { + serverContent, exists := serverByKey[key] + switch { + case !exists: + diff.Added = append(diff.Added, key) + case localContent != serverContent: + diff.Modified = append(diff.Modified, key) + } + } + for key := range serverByKey { + if _, exists := localByKey[key]; !exists { + diff.Removed = append(diff.Removed, key) + } + } + + sortCategory(&diff) + return diff +} + +func diffAssets(local []LocalAsset, server *api.Theme) CategoryDiff { + localByKey := make(map[string]string, len(local)) + for _, a := range local { + localByKey[a.Key] = a.ContentHash + } + serverByKey := make(map[string]string, len(server.Assets)) + for _, a := range server.Assets { + serverByKey[a.Key] = a.ContentHash + } + + var diff CategoryDiff + for key, localHash := range localByKey { + serverHash, exists := serverByKey[key] + switch { + case !exists: + diff.Added = append(diff.Added, key) + case localHash != serverHash: + diff.Modified = append(diff.Modified, key) + } + } + for key := range serverByKey { + if _, exists := localByKey[key]; !exists { + diff.Removed = append(diff.Removed, key) + } + } + + sortCategory(&diff) + return diff +} + +func sortCategory(c *CategoryDiff) { + sort.Strings(c.Added) + sort.Strings(c.Modified) + sort.Strings(c.Removed) +} diff --git a/internal/theme/diff_test.go b/internal/theme/diff_test.go new file mode 100644 index 0000000..f0eb0e2 --- /dev/null +++ b/internal/theme/diff_test.go @@ -0,0 +1,126 @@ +package theme + +import ( + "reflect" + "testing" + + "github.com/GetStoreConnect/storeconnect-cli/internal/api" +) + +func tmpl(key, content string) api.ThemeTemplate { + return api.ThemeTemplate{Key: key, Content: content} +} + +func TestComputeDiffTemplates(t *testing.T) { + local := &api.Theme{Templates: []api.ThemeTemplate{ + tmpl("layouts/theme", "CHANGED"), // modified + tmpl("pages/home", "same"), // unchanged + tmpl("pages/new", "brand new"), // added + }} + server := &api.Theme{Templates: []api.ThemeTemplate{ + tmpl("layouts/theme", "original"), + tmpl("pages/home", "same"), + tmpl("pages/gone", "old"), // removed + }} + + diff := ComputeDiff(local, nil, server) + + if !reflect.DeepEqual(diff.Templates.Added, []string{"pages/new"}) { + t.Errorf("added = %v", diff.Templates.Added) + } + if !reflect.DeepEqual(diff.Templates.Modified, []string{"layouts/theme"}) { + t.Errorf("modified = %v", diff.Templates.Modified) + } + if !reflect.DeepEqual(diff.Templates.Removed, []string{"pages/gone"}) { + t.Errorf("removed = %v", diff.Templates.Removed) + } +} + +func TestComputeDiffAssets(t *testing.T) { + local := []LocalAsset{ + {Key: "logo.png", ContentHash: "newhash"}, // modified + {Key: "fonts/brand.woff2", ContentHash: "abc"}, // added + {Key: "icon.svg", ContentHash: "same"}, // unchanged + } + server := &api.Theme{Assets: []api.ThemeAsset{ + {Key: "logo.png", ContentHash: "oldhash"}, + {Key: "icon.svg", ContentHash: "same"}, + {Key: "legacy.gif", ContentHash: "x"}, // removed + }} + + diff := ComputeDiff(&api.Theme{}, local, server) + + if !reflect.DeepEqual(diff.Assets.Added, []string{"fonts/brand.woff2"}) { + t.Errorf("added = %v", diff.Assets.Added) + } + if !reflect.DeepEqual(diff.Assets.Modified, []string{"logo.png"}) { + t.Errorf("modified = %v", diff.Assets.Modified) + } + if !reflect.DeepEqual(diff.Assets.Removed, []string{"legacy.gif"}) { + t.Errorf("removed = %v", diff.Assets.Removed) + } +} + +func TestComputeDiffInSync(t *testing.T) { + local := &api.Theme{Templates: []api.ThemeTemplate{tmpl("layouts/theme", "x")}} + localAssets := []LocalAsset{{Key: "logo.png", ContentHash: "h"}} + server := &api.Theme{ + Templates: []api.ThemeTemplate{tmpl("layouts/theme", "x")}, + Assets: []api.ThemeAsset{{Key: "logo.png", ContentHash: "h"}}, + } + + diff := ComputeDiff(local, localAssets, server) + + if diff.HasChanges() { + t.Errorf("expected no changes, got %+v", diff) + } + if diff.PushableCount() != 0 || diff.ServerOnlyCount() != 0 { + t.Errorf("expected zero counts, got pushable=%d serverOnly=%d", diff.PushableCount(), diff.ServerOnlyCount()) + } +} + +func TestComputeDiffCounts(t *testing.T) { + local := &api.Theme{Templates: []api.ThemeTemplate{ + tmpl("a", "1"), // added + tmpl("b", "changed"), // modified + }} + localAssets := []LocalAsset{{Key: "c.png", ContentHash: "new"}} // added + server := &api.Theme{ + Templates: []api.ThemeTemplate{ + tmpl("b", "orig"), + tmpl("d", "x"), // removed + }, + Assets: []api.ThemeAsset{{Key: "e.png", ContentHash: "y"}}, // removed + } + + diff := ComputeDiff(local, localAssets, server) + + if !diff.HasChanges() { + t.Fatal("expected changes") + } + // added a, modified b, added c.png = 3 pushable + if diff.PushableCount() != 3 { + t.Errorf("pushable = %d, want 3", diff.PushableCount()) + } + // removed d, removed e.png = 2 server-only + if diff.ServerOnlyCount() != 2 { + t.Errorf("serverOnly = %d, want 2", diff.ServerOnlyCount()) + } +} + +func TestComputeDiffEmptyServer(t *testing.T) { + local := &api.Theme{Templates: []api.ThemeTemplate{tmpl("a", "1")}} + localAssets := []LocalAsset{{Key: "b.png", ContentHash: "h"}} + + diff := ComputeDiff(local, localAssets, &api.Theme{}) + + if !reflect.DeepEqual(diff.Templates.Added, []string{"a"}) { + t.Errorf("templates added = %v", diff.Templates.Added) + } + if !reflect.DeepEqual(diff.Assets.Added, []string{"b.png"}) { + t.Errorf("assets added = %v", diff.Assets.Added) + } + if diff.ServerOnlyCount() != 0 { + t.Errorf("serverOnly = %d, want 0", diff.ServerOnlyCount()) + } +}