Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/commands/exit_codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
17 changes: 16 additions & 1 deletion internal/commands/responses.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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"`
}
162 changes: 162 additions & 0 deletions internal/commands/theme_diff.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
126 changes: 126 additions & 0 deletions internal/theme/diff.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading