diff --git a/internal/api/content_changes.go b/internal/api/content_changes.go
index c8732c9..1871882 100644
--- a/internal/api/content_changes.go
+++ b/internal/api/content_changes.go
@@ -4,16 +4,20 @@ import "errors"
// ContentChange represents a draft change set
type ContentChange struct {
- SCID string `json:"sc_id"`
- SFID string `json:"sfid,omitempty"`
- Status string `json:"status"`
- CustomData map[string]interface{} `json:"custom_data,omitempty"`
+ SCID string `json:"sc_id"`
+ SFID string `json:"sfid,omitempty"`
+ Status string `json:"status"`
+ Summary string `json:"summary,omitempty"`
+ RecordsCount int `json:"records_count,omitempty"`
+ CreatedAt string `json:"created_at,omitempty"`
+ CustomData map[string]interface{} `json:"custom_data,omitempty"`
}
// ContentChangeRequest represents a request to create/update content changes
type ContentChangeRequest struct {
ThemeID string `json:"theme_id,omitempty"`
Templates []ContentChangeTemplate `json:"templates,omitempty"`
+ Assets []ContentChangeAsset `json:"assets,omitempty"`
}
// ContentChangeTemplate represents a template change
@@ -23,6 +27,17 @@ type ContentChangeTemplate struct {
Action string `json:"action"` // "create", "update", or "delete"
}
+// ContentChangeAsset represents an uploaded or changed binary asset that
+// should be registered on the draft. URL is the hosted location returned by
+// the media upload flow; ContentHash lets the server skip re-processing
+// unchanged binaries.
+type ContentChangeAsset struct {
+ Key string `json:"key"`
+ URL string `json:"url"`
+ ContentType string `json:"content_type,omitempty"`
+ ContentHash string `json:"content_hash,omitempty"`
+}
+
// PreviewURLResponse represents the preview URL response
type PreviewURLResponse struct {
PreviewURL string `json:"preview_url"`
@@ -62,17 +77,43 @@ func (cc *ContentChanges) Create(themeID string) (*ContentChange, error) {
return &result, nil
}
-// Update adds template changes to an existing content change
-func (cc *ContentChanges) Update(id string, themeID string, templates []ContentChangeTemplate) error {
- body := map[string]interface{}{
- "theme_id": themeID,
- "templates": templates,
+// Update adds template and asset changes to an existing content change.
+//
+// Assets are the binaries already uploaded through the media flow; passing an
+// empty slice simply omits them from the request body.
+func (cc *ContentChanges) Update(id string, themeID string, templates []ContentChangeTemplate, assets []ContentChangeAsset) error {
+ body := ContentChangeRequest{
+ ThemeID: themeID,
+ Templates: templates,
+ Assets: assets,
}
var result ContentChange
return cc.client.Patch("/api/v1/content_changes/"+id, body, &result)
}
+// List returns the store's content changes, optionally filtered by status.
+//
+// Drafts created from one machine can be discovered and resumed from another
+// by listing them here.
+func (cc *ContentChanges) List(status string) ([]ContentChange, error) {
+ var result struct {
+ Data []ContentChange `json:"data"`
+ }
+
+ var params map[string]string
+ if status != "" {
+ params = map[string]string{"status": status}
+ }
+
+ err := cc.client.Get("/api/v1/content_changes", &result, params)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Data, nil
+}
+
// GetPreviewURL gets the preview URL for a content change
func (cc *ContentChanges) GetPreviewURL(id string) (string, error) {
var result PreviewURLResponse
diff --git a/internal/api/content_changes_test.go b/internal/api/content_changes_test.go
index e81f463..79bcf83 100644
--- a/internal/api/content_changes_test.go
+++ b/internal/api/content_changes_test.go
@@ -95,10 +95,92 @@ func TestContentChangesUpdate(t *testing.T) {
client := NewClient(server.URL, "test-store", "test-key")
ccService := NewContentChanges(client)
- err := ccService.Update("cc-123", "theme-123", templates)
+ err := ccService.Update("cc-123", "theme-123", templates, nil)
require.NoError(t, err)
}
+func TestContentChangesUpdateIncludesAssets(t *testing.T) {
+ templates := []ContentChangeTemplate{
+ {Key: "pages/home", Content: "
Home
", Action: "update"},
+ }
+ assets := []ContentChangeAsset{
+ {Key: "images/logo.png", URL: "https://cdn.example.com/logo.png", ContentType: "image/png", ContentHash: "hash1"},
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/api/v1/content_changes/cc-123", r.URL.Path)
+ assert.Equal(t, http.MethodPatch, r.Method)
+
+ var body map[string]interface{}
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
+
+ require.Contains(t, body, "assets")
+ rawAssets, ok := body["assets"].([]interface{})
+ require.True(t, ok)
+ require.Len(t, rawAssets, 1)
+
+ asset, ok := rawAssets[0].(map[string]interface{})
+ require.True(t, ok)
+ assert.Equal(t, "images/logo.png", asset["key"])
+ assert.Equal(t, "https://cdn.example.com/logo.png", asset["url"])
+ assert.Equal(t, "image/png", asset["content_type"])
+ assert.Equal(t, "hash1", asset["content_hash"])
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(ContentChange{SCID: "cc-123", Status: "draft"})
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "test-store", "test-key")
+ ccService := NewContentChanges(client)
+
+ err := ccService.Update("cc-123", "theme-123", templates, assets)
+ require.NoError(t, err)
+}
+
+func TestContentChangesList(t *testing.T) {
+ tests := []struct {
+ name string
+ status string
+ wantStatus string
+ wantLen int
+ }{
+ {name: "no status filter", status: "", wantStatus: "", wantLen: 2},
+ {name: "filtered by status", status: "draft", wantStatus: "draft", wantLen: 2},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/api/v1/content_changes", r.URL.Path)
+ assert.Equal(t, http.MethodGet, r.Method)
+ assert.Equal(t, tt.wantStatus, r.URL.Query().Get("status"))
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "data": []ContentChange{
+ {SCID: "cc-1", Status: "draft", Summary: "First", RecordsCount: 3, CreatedAt: "2026-06-12T00:00:00Z"},
+ {SCID: "cc-2", Status: "draft", Summary: "Second", RecordsCount: 1},
+ },
+ })
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "test-store", "test-key")
+ ccService := NewContentChanges(client)
+
+ changes, err := ccService.List(tt.status)
+ require.NoError(t, err)
+ require.Len(t, changes, tt.wantLen)
+ assert.Equal(t, "cc-1", changes[0].SCID)
+ assert.Equal(t, "First", changes[0].Summary)
+ assert.Equal(t, 3, changes[0].RecordsCount)
+ })
+ }
+}
+
func TestContentChangesGetPreviewURL(t *testing.T) {
tests := []struct {
name string
diff --git a/internal/api/media.go b/internal/api/media.go
index f04e272..1f8d175 100644
--- a/internal/api/media.go
+++ b/internal/api/media.go
@@ -1,5 +1,15 @@
package api
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "time"
+)
+
// MediaAsset represents a media asset
type MediaAsset struct {
SCID string `json:"sc_id"`
@@ -10,20 +20,46 @@ type MediaAsset struct {
Size int64 `json:"size,omitempty"`
}
-// UploadURLResponse represents the upload URL response
+// UploadURLResponse represents the response from POST /api/v1/media/upload_url.
+//
+// The server hands back a target URL plus a set of form parameters that must
+// accompany the binary in the follow-up multipart POST.
type UploadURLResponse struct {
- UploadURL string `json:"upload_url"`
- AssetID string `json:"asset_id"`
+ UploadURL string `json:"upload_url"`
+ UploadParams map[string]string `json:"upload_params"`
+ AssetID string `json:"asset_id,omitempty"`
+}
+
+// UploadResult is the parsed response from the upload host. The hosted URL is
+// "secure_url" when present, falling back to "url".
+type UploadResult struct {
+ SecureURL string `json:"secure_url"`
+ URL string `json:"url"`
+}
+
+// HostedURL returns the canonical hosted location, preferring the secure URL.
+func (r UploadResult) HostedURL() string {
+ if r.SecureURL != "" {
+ return r.SecureURL
+ }
+ return r.URL
}
// Media handles media-related endpoints
type Media struct {
client *Client
+
+ // uploader performs the multipart POST to the (external) upload host.
+ // It is overridable in tests; production uses the default HTTP client.
+ uploader *http.Client
}
// NewMedia creates a new Media service
func NewMedia(client *Client) *Media {
- return &Media{client: client}
+ return &Media{
+ client: client,
+ uploader: &http.Client{Timeout: 60 * time.Second},
+ }
}
// List returns all media assets
@@ -40,9 +76,13 @@ func (m *Media) List() ([]MediaAsset, error) {
return result.Media, nil
}
-// GetUploadURL gets a presigned upload URL
-func (m *Media) GetUploadURL(filename, contentType string) (*UploadURLResponse, error) {
+// UploadURL requests a signed upload target for a single file.
+//
+// fileType is the StoreConnect media kind ("image" or "document"), filename is
+// the asset key/name, and contentType is the MIME type inferred from the file.
+func (m *Media) UploadURL(fileType, filename, contentType string) (*UploadURLResponse, error) {
body := map[string]interface{}{
+ "file_type": fileType,
"filename": filename,
"content_type": contentType,
}
@@ -56,17 +96,61 @@ func (m *Media) GetUploadURL(filename, contentType string) (*UploadURLResponse,
return &result, nil
}
-// ConfirmUpload confirms a completed upload
-func (m *Media) ConfirmUpload(assetID string) (*MediaAsset, error) {
- body := map[string]interface{}{
- "asset_id": assetID,
+// UploadFile streams the binary to the upload host as a multipart POST,
+// sending every upload param as a form field plus the bytes as the "file"
+// field. It returns the hosted URL the server assigned to the upload.
+func (m *Media) UploadFile(upload *UploadURLResponse, filename string, content []byte) (string, error) {
+ var buf bytes.Buffer
+ writer := multipart.NewWriter(&buf)
+
+ for key, value := range upload.UploadParams {
+ if err := writer.WriteField(key, value); err != nil {
+ return "", fmt.Errorf("failed to write upload param %q: %w", key, err)
+ }
}
- var result MediaAsset
- err := m.client.Post("/api/v1/media/confirm_upload", body, &result)
+ part, err := writer.CreateFormFile("file", filename)
if err != nil {
- return nil, err
+ return "", fmt.Errorf("failed to create file field: %w", err)
+ }
+ if _, err := part.Write(content); err != nil {
+ return "", fmt.Errorf("failed to write file contents: %w", err)
}
- return &result, nil
+ if err := writer.Close(); err != nil {
+ return "", fmt.Errorf("failed to finalize upload body: %w", err)
+ }
+
+ req, err := http.NewRequest(http.MethodPost, upload.UploadURL, &buf)
+ if err != nil {
+ return "", fmt.Errorf("failed to build upload request: %w", err)
+ }
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+
+ resp, err := m.uploader.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("failed to upload file: %w", err)
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("failed to read upload response: %w", err)
+ }
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return "", fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(respBody))
+ }
+
+ var result UploadResult
+ if err := json.Unmarshal(respBody, &result); err != nil {
+ return "", fmt.Errorf("failed to parse upload response: %w", err)
+ }
+
+ hosted := result.HostedURL()
+ if hosted == "" {
+ return "", fmt.Errorf("upload response missing hosted URL")
+ }
+
+ return hosted, nil
}
diff --git a/internal/api/media_test.go b/internal/api/media_test.go
new file mode 100644
index 0000000..def285b
--- /dev/null
+++ b/internal/api/media_test.go
@@ -0,0 +1,143 @@
+package api
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMediaUploadURL(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/api/v1/media/upload_url", r.URL.Path)
+ assert.Equal(t, http.MethodPost, r.Method)
+
+ var body map[string]interface{}
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
+ assert.Equal(t, "image", body["file_type"])
+ assert.Equal(t, "logo.png", body["filename"])
+ assert.Equal(t, "image/png", body["content_type"])
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "upload_url": "https://uploads.example.com/signed",
+ "upload_params": map[string]string{
+ "signature": "abc",
+ "timestamp": "123",
+ },
+ "asset_id": "asset-1",
+ })
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "test-store", "test-key")
+ media := NewMedia(client)
+
+ resp, err := media.UploadURL("image", "logo.png", "image/png")
+ require.NoError(t, err)
+ assert.Equal(t, "https://uploads.example.com/signed", resp.UploadURL)
+ assert.Equal(t, "abc", resp.UploadParams["signature"])
+ assert.Equal(t, "123", resp.UploadParams["timestamp"])
+ assert.Equal(t, "asset-1", resp.AssetID)
+}
+
+func TestMediaUploadFile(t *testing.T) {
+ tests := []struct {
+ name string
+ response map[string]interface{}
+ wantURL string
+ wantErr bool
+ errContains string
+ }{
+ {
+ name: "prefers secure_url",
+ response: map[string]interface{}{"secure_url": "https://cdn.example.com/secure.png", "url": "http://cdn.example.com/plain.png"},
+ wantURL: "https://cdn.example.com/secure.png",
+ },
+ {
+ name: "falls back to url",
+ response: map[string]interface{}{"url": "http://cdn.example.com/plain.png"},
+ wantURL: "http://cdn.example.com/plain.png",
+ },
+ {
+ name: "missing hosted url",
+ response: map[string]interface{}{"public_id": "x"},
+ wantErr: true,
+ errContains: "missing hosted URL",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodPost, r.Method)
+
+ // Parse the multipart body and assert the params + file arrive.
+ require.NoError(t, r.ParseMultipartForm(10<<20))
+
+ assert.Equal(t, "sig-value", r.FormValue("signature"))
+ assert.Equal(t, "1700000000", r.FormValue("timestamp"))
+
+ file, header, err := r.FormFile("file")
+ require.NoError(t, err)
+ defer file.Close()
+ // Go's multipart reader returns the base name (path components in
+ // the Content-Disposition filename are stripped by the stdlib).
+ assert.Equal(t, "logo.png", header.Filename)
+ contents, err := io.ReadAll(file)
+ require.NoError(t, err)
+ assert.Equal(t, []byte("binary-bytes"), contents)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(tt.response)
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "test-store", "test-key")
+ media := NewMedia(client)
+
+ upload := &UploadURLResponse{
+ UploadURL: server.URL,
+ UploadParams: map[string]string{
+ "signature": "sig-value",
+ "timestamp": "1700000000",
+ },
+ }
+
+ hosted, err := media.UploadFile(upload, "images/logo.png", []byte("binary-bytes"))
+
+ if tt.wantErr {
+ require.Error(t, err)
+ if tt.errContains != "" {
+ assert.Contains(t, err.Error(), tt.errContains)
+ }
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantURL, hosted)
+ }
+ })
+ }
+}
+
+func TestMediaUploadFileServerError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte("boom"))
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "test-store", "test-key")
+ media := NewMedia(client)
+
+ upload := &UploadURLResponse{UploadURL: server.URL, UploadParams: map[string]string{}}
+ _, err := media.UploadFile(upload, "logo.png", []byte("x"))
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "status 500")
+}
diff --git a/internal/api/themes.go b/internal/api/themes.go
index 68e059c..2473c62 100644
--- a/internal/api/themes.go
+++ b/internal/api/themes.go
@@ -16,11 +16,17 @@ type ThemeTemplate struct {
Content string `json:"content"`
}
-// ThemeAsset represents an asset in a theme
+// ThemeAsset represents an asset in a theme.
+//
+// The server contract for GET /api/v1/themes/:id returns assets as
+// [{key, url, content_type, content_hash}]. Key is the asset's path within
+// the theme (it may contain slashes), content_hash is the SHA-256 hex digest
+// of the binary, used to skip uploads of unchanged files.
type ThemeAsset struct {
- Filename string `json:"filename"`
- ContentType string `json:"content_type"`
- URL string `json:"url"`
+ Key string `json:"key"`
+ URL string `json:"url,omitempty"`
+ ContentType string `json:"content_type,omitempty"`
+ ContentHash string `json:"content_hash,omitempty"`
}
// Themes handles theme-related endpoints
diff --git a/internal/commands/change_list.go b/internal/commands/change_list.go
new file mode 100644
index 0000000..7db01ee
--- /dev/null
+++ b/internal/commands/change_list.go
@@ -0,0 +1,89 @@
+package commands
+
+import (
+ "fmt"
+
+ "github.com/GetStoreConnect/storeconnect-cli/internal/api"
+ "github.com/GetStoreConnect/storeconnect-cli/internal/ui"
+ "github.com/spf13/cobra"
+)
+
+var changeCmd = &cobra.Command{
+ Use: "change",
+ Short: "Content change management commands",
+ Long: `Commands for working with StoreConnect content changes (drafts) so they can be resumed from any machine.`,
+}
+
+var changeListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List content changes",
+ Long: `List the store's content changes (drafts).
+
+Use --status to filter by status (e.g. draft, review, published). Drafts
+created on one machine can be discovered and resumed from another.`,
+ RunE: runChangeList,
+}
+
+func init() {
+ rootCmd.AddCommand(changeCmd)
+ changeCmd.AddCommand(changeListCmd)
+
+ changeListCmd.Flags().String("status", "", "filter content changes by status (e.g. draft, review, published)")
+}
+
+func runChangeList(cmd *cobra.Command, args []string) error {
+ formatter := ui.NewFormatter()
+ status, _ := cmd.Flags().GetString("status")
+
+ client, serverAlias, err := getAPIClient(cmd)
+ if err != nil {
+ if !jsonOutput {
+ formatter.Error(fmt.Sprintf("Failed to get API client: %v", err))
+ }
+ return outputError(err)
+ }
+
+ changesService := api.NewContentChanges(client)
+
+ var spinner *ui.Spinner
+ if !jsonOutput {
+ spinner = ui.NewSpinner("Fetching content changes")
+ spinner.Start()
+ }
+
+ changes, err := changesService.List(status)
+ if err != nil {
+ if spinner != nil {
+ spinner.Error(fmt.Sprintf("Failed to fetch content changes: %v", err))
+ }
+ return outputError(err)
+ }
+
+ if spinner != nil {
+ spinner.Stop()
+ }
+
+ // JSON output
+ if jsonOutput {
+ return outputResponse(changes, nil)
+ }
+
+ // Human-friendly table
+ if len(changes) == 0 {
+ formatter.Warning("No content changes found")
+ return nil
+ }
+
+ formatter.Info(fmt.Sprintf("Content changes on %s:", serverAlias))
+ fmt.Println()
+
+ fmt.Printf("%-22s %-12s %-8s %s\n", "SC ID", "STATUS", "RECORDS", "SUMMARY")
+ for _, change := range changes {
+ fmt.Printf("%-22s %-12s %-8d %s\n", change.SCID, change.Status, change.RecordsCount, change.Summary)
+ }
+
+ fmt.Println()
+ formatter.Dim(fmt.Sprintf("Total: %d content changes", len(changes)))
+
+ return nil
+}
diff --git a/internal/commands/theme_push.go b/internal/commands/theme_push.go
index 97a1cb6..7f57552 100644
--- a/internal/commands/theme_push.go
+++ b/internal/commands/theme_push.go
@@ -112,6 +112,30 @@ func runThemePush(cmd *cobra.Command, args []string) error {
// Create API client
client := api.NewClient(cred.URL, cred.StoreSFID, cred.APIKey, api.WithOrgID(cred.OrgID))
contentChangesService := api.NewContentChanges(client)
+ themesService := api.NewThemes(client)
+ mediaService := api.NewMedia(client)
+
+ // Determine which local assets need uploading by hash-diffing against the
+ // server's copy of the theme. Unchanged binaries are skipped entirely.
+ localAssets, err := theme.ReadLocalAssets(fmt.Sprintf("themes/%s", themeName))
+ if err != nil {
+ if spinner != nil {
+ spinner.Error(fmt.Sprintf("Failed to read assets: %v", err))
+ }
+ return outputError(err)
+ }
+
+ var changedAssets []theme.LocalAsset
+ if len(localAssets) > 0 {
+ 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)
+ }
+ changedAssets = theme.SelectChangedAssets(localAssets, serverTheme.Assets)
+ }
// Create content change (draft)
contentChange, err := contentChangesService.Create(themeID)
@@ -122,6 +146,39 @@ func runThemePush(cmd *cobra.Command, args []string) error {
return outputError(err)
}
+ // Upload each changed asset binary through the media flow and collect the
+ // hosted URLs so they can be registered on the draft.
+ assets := make([]api.ContentChangeAsset, 0, len(changedAssets))
+ for _, asset := range changedAssets {
+ if spinner != nil {
+ spinner.UpdateMessage(fmt.Sprintf("Uploading asset %s", asset.Key))
+ }
+
+ fileType := theme.InferFileType(asset.Key)
+ upload, err := mediaService.UploadURL(fileType, asset.Key, asset.ContentType)
+ if err != nil {
+ if spinner != nil {
+ spinner.Error(fmt.Sprintf("Failed to get upload URL for %s: %v", asset.Key, err))
+ }
+ return outputError(err)
+ }
+
+ hostedURL, err := mediaService.UploadFile(upload, asset.Key, asset.Content)
+ if err != nil {
+ if spinner != nil {
+ spinner.Error(fmt.Sprintf("Failed to upload asset %s: %v", asset.Key, err))
+ }
+ return outputError(err)
+ }
+
+ assets = append(assets, api.ContentChangeAsset{
+ Key: asset.Key,
+ URL: hostedURL,
+ ContentType: asset.ContentType,
+ ContentHash: asset.ContentHash,
+ })
+ }
+
if spinner != nil {
spinner.UpdateMessage("Uploading templates")
}
@@ -136,9 +193,9 @@ func runThemePush(cmd *cobra.Command, args []string) error {
}
}
- // Update content change with templates
- if len(templates) > 0 {
- if err := contentChangesService.Update(contentChange.SCID, themeID, templates); err != nil {
+ // Update content change with templates and any uploaded assets
+ if len(templates) > 0 || len(assets) > 0 {
+ if err := contentChangesService.Update(contentChange.SCID, themeID, templates, assets); err != nil {
if spinner != nil {
spinner.Error(fmt.Sprintf("Failed to upload templates: %v", err))
}
diff --git a/internal/testutil/fixtures.go b/internal/testutil/fixtures.go
index 40b55b9..22f2951 100644
--- a/internal/testutil/fixtures.go
+++ b/internal/testutil/fixtures.go
@@ -20,9 +20,10 @@ func TestTheme() *api.Theme {
},
Assets: []api.ThemeAsset{
{
- Filename: "logo.png",
- ContentType: "image/png",
+ Key: "logo.png",
URL: "https://example.com/logo.png",
+ ContentType: "image/png",
+ ContentHash: "abc123",
},
},
Variables: map[string]interface{}{
diff --git a/internal/theme/assets.go b/internal/theme/assets.go
new file mode 100644
index 0000000..5e1868e
--- /dev/null
+++ b/internal/theme/assets.go
@@ -0,0 +1,146 @@
+package theme
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/GetStoreConnect/storeconnect-cli/internal/api"
+)
+
+// AssetsDirName is the directory inside a theme that holds binary assets.
+const AssetsDirName = "assets"
+
+// LocalAsset is a binary asset read from the local theme's assets/ directory.
+// Key is the path relative to assets/ (forward-slashed, may contain slashes),
+// matching the server's asset key contract.
+type LocalAsset struct {
+ Key string
+ Path string
+ ContentType string
+ ContentHash string
+ Content []byte
+}
+
+// ReadLocalAssets walks the assets/ directory inside themeDir and returns every
+// file as a LocalAsset with its SHA-256 hex digest computed. A missing assets/
+// directory is not an error - it simply yields no assets.
+func ReadLocalAssets(themeDir string) ([]LocalAsset, error) {
+ assetsDir := filepath.Join(themeDir, AssetsDirName)
+
+ if _, err := os.Stat(assetsDir); os.IsNotExist(err) {
+ return nil, nil
+ }
+
+ var assets []LocalAsset
+
+ err := filepath.Walk(assetsDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() {
+ return nil
+ }
+
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+
+ relPath, err := filepath.Rel(assetsDir, path)
+ if err != nil {
+ return err
+ }
+ key := filepath.ToSlash(relPath)
+
+ assets = append(assets, LocalAsset{
+ Key: key,
+ Path: path,
+ ContentType: InferContentType(key),
+ ContentHash: HashContent(content),
+ Content: content,
+ })
+
+ return nil
+ })
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to read assets: %w", err)
+ }
+
+ return assets, nil
+}
+
+// HashContent returns the SHA-256 hex digest of the given bytes.
+func HashContent(content []byte) string {
+ sum := sha256.Sum256(content)
+ return hex.EncodeToString(sum[:])
+}
+
+// SelectChangedAssets returns the local assets that need uploading: those whose
+// key is absent on the server, or whose content hash differs from the server's.
+// Assets whose hash matches the server are skipped. This is a pure function so
+// the selection rule is trivially unit-testable.
+func SelectChangedAssets(local []LocalAsset, server []api.ThemeAsset) []LocalAsset {
+ serverHashes := make(map[string]string, len(server))
+ for _, a := range server {
+ serverHashes[a.Key] = a.ContentHash
+ }
+
+ var changed []LocalAsset
+ for _, a := range local {
+ serverHash, exists := serverHashes[a.Key]
+ if !exists || serverHash != a.ContentHash {
+ changed = append(changed, a)
+ }
+ }
+
+ return changed
+}
+
+// imageExtensions are the file extensions treated as images by the media flow.
+var imageExtensions = map[string]bool{
+ ".png": true,
+ ".jpg": true,
+ ".jpeg": true,
+ ".gif": true,
+ ".webp": true,
+ ".svg": true,
+}
+
+// contentTypes maps known image extensions to their MIME type.
+var contentTypes = map[string]string{
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ ".svg": "image/svg+xml",
+ ".css": "text/css",
+ ".js": "application/javascript",
+ ".json": "application/json",
+ ".pdf": "application/pdf",
+}
+
+// InferContentType guesses the MIME type from a filename's extension, defaulting
+// to application/octet-stream for unknown extensions.
+func InferContentType(filename string) string {
+ ext := strings.ToLower(filepath.Ext(filename))
+ if ct, ok := contentTypes[ext]; ok {
+ return ct
+ }
+ return "application/octet-stream"
+}
+
+// InferFileType returns the StoreConnect media file_type for a filename:
+// "image" for known image extensions, "document" otherwise.
+func InferFileType(filename string) string {
+ ext := strings.ToLower(filepath.Ext(filename))
+ if imageExtensions[ext] {
+ return "image"
+ }
+ return "document"
+}
diff --git a/internal/theme/assets_test.go b/internal/theme/assets_test.go
new file mode 100644
index 0000000..7b8d068
--- /dev/null
+++ b/internal/theme/assets_test.go
@@ -0,0 +1,148 @@
+package theme
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/GetStoreConnect/storeconnect-cli/internal/api"
+ "github.com/GetStoreConnect/storeconnect-cli/internal/testutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSelectChangedAssets(t *testing.T) {
+ tests := []struct {
+ name string
+ local []LocalAsset
+ server []api.ThemeAsset
+ wantKeys []string
+ }{
+ {
+ name: "new asset absent on server is selected",
+ local: []LocalAsset{
+ {Key: "images/logo.png", ContentHash: "hashA"},
+ },
+ server: nil,
+ wantKeys: []string{"images/logo.png"},
+ },
+ {
+ name: "changed hash is selected",
+ local: []LocalAsset{
+ {Key: "images/logo.png", ContentHash: "hashNEW"},
+ },
+ server: []api.ThemeAsset{
+ {Key: "images/logo.png", ContentHash: "hashOLD"},
+ },
+ wantKeys: []string{"images/logo.png"},
+ },
+ {
+ name: "unchanged hash is skipped",
+ local: []LocalAsset{
+ {Key: "images/logo.png", ContentHash: "same"},
+ },
+ server: []api.ThemeAsset{
+ {Key: "images/logo.png", ContentHash: "same"},
+ },
+ wantKeys: nil,
+ },
+ {
+ name: "mixed selects only new and changed",
+ local: []LocalAsset{
+ {Key: "a.png", ContentHash: "1"}, // unchanged
+ {Key: "b.png", ContentHash: "2-new"}, // changed
+ {Key: "c.png", ContentHash: "3"}, // new
+ },
+ server: []api.ThemeAsset{
+ {Key: "a.png", ContentHash: "1"},
+ {Key: "b.png", ContentHash: "2-old"},
+ },
+ wantKeys: []string{"b.png", "c.png"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ changed := SelectChangedAssets(tt.local, tt.server)
+
+ var gotKeys []string
+ for _, a := range changed {
+ gotKeys = append(gotKeys, a.Key)
+ }
+ assert.Equal(t, tt.wantKeys, gotKeys)
+ })
+ }
+}
+
+func TestReadLocalAssets(t *testing.T) {
+ basePath, cleanup := testutil.CreateTempProject(t)
+ defer cleanup()
+
+ themeDir := filepath.Join(basePath, "themes", "Test")
+ assetsDir := filepath.Join(themeDir, AssetsDirName)
+ testutil.CreateTestDir(t, filepath.Join(assetsDir, "images"))
+
+ testutil.WriteTestFile(t, filepath.Join(assetsDir, "style.css"), "body{}")
+ testutil.WriteTestFile(t, filepath.Join(assetsDir, "images", "logo.png"), "PNGDATA")
+
+ assets, err := ReadLocalAssets(themeDir)
+ require.NoError(t, err)
+ require.Len(t, assets, 2)
+
+ byKey := map[string]LocalAsset{}
+ for _, a := range assets {
+ byKey[a.Key] = a
+ }
+
+ // Nested key uses forward slashes regardless of platform.
+ logo, ok := byKey["images/logo.png"]
+ require.True(t, ok)
+ assert.Equal(t, "image/png", logo.ContentType)
+ assert.Equal(t, HashContent([]byte("PNGDATA")), logo.ContentHash)
+ assert.Equal(t, []byte("PNGDATA"), logo.Content)
+
+ css, ok := byKey["style.css"]
+ require.True(t, ok)
+ assert.Equal(t, "text/css", css.ContentType)
+}
+
+func TestReadLocalAssetsMissingDir(t *testing.T) {
+ basePath, cleanup := testutil.CreateTempProject(t)
+ defer cleanup()
+
+ themeDir := filepath.Join(basePath, "themes", "NoAssets")
+ testutil.CreateTestDir(t, themeDir)
+
+ assets, err := ReadLocalAssets(themeDir)
+ require.NoError(t, err)
+ assert.Empty(t, assets)
+}
+
+func TestInferContentType(t *testing.T) {
+ cases := map[string]string{
+ "logo.png": "image/png",
+ "photo.JPG": "image/jpeg",
+ "icon.svg": "image/svg+xml",
+ "style.css": "text/css",
+ "app.js": "application/javascript",
+ "data.bin": "application/octet-stream",
+ "noext": "application/octet-stream",
+ "manual.pdf": "application/pdf",
+ }
+ for name, want := range cases {
+ assert.Equal(t, want, InferContentType(name), name)
+ }
+}
+
+func TestInferFileType(t *testing.T) {
+ cases := map[string]string{
+ "logo.png": "image",
+ "photo.jpeg": "image",
+ "icon.SVG": "image",
+ "style.css": "document",
+ "manual.pdf": "document",
+ "noext": "document",
+ }
+ for name, want := range cases {
+ assert.Equal(t, want, InferFileType(name), name)
+ }
+}
diff --git a/internal/theme/deserializer.go b/internal/theme/deserializer.go
index 3d23714..1438697 100644
--- a/internal/theme/deserializer.go
+++ b/internal/theme/deserializer.go
@@ -61,14 +61,17 @@ func (d *Deserializer) Deserialize(themeName string) (*api.Theme, error) {
for _, a := range assetsList {
if assetMap, ok := a.(map[string]interface{}); ok {
asset := api.ThemeAsset{}
- if filename, ok := assetMap["filename"].(string); ok {
- asset.Filename = filename
+ if key, ok := assetMap["key"].(string); ok {
+ asset.Key = key
+ }
+ if url, ok := assetMap["url"].(string); ok {
+ asset.URL = url
}
if contentType, ok := assetMap["content_type"].(string); ok {
asset.ContentType = contentType
}
- if url, ok := assetMap["url"].(string); ok {
- asset.URL = url
+ if contentHash, ok := assetMap["content_hash"].(string); ok {
+ asset.ContentHash = contentHash
}
theme.Assets = append(theme.Assets, asset)
}
diff --git a/internal/theme/deserializer_test.go b/internal/theme/deserializer_test.go
index 535c6b7..eac53b4 100644
--- a/internal/theme/deserializer_test.go
+++ b/internal/theme/deserializer_test.go
@@ -33,7 +33,8 @@ func TestDeserializer_Deserialize(t *testing.T) {
setup: func(t *testing.T, basePath string) string {
// Serialize a complete theme first
theme := testutil.TestTheme()
- serializer := NewSerializer(basePath)
+ serializer := NewSerializer(basePath).
+ WithDownloader(func(url string) ([]byte, error) { return []byte("stub-asset"), nil })
err := serializer.Serialize(theme)
require.NoError(t, err)
return theme.Name
@@ -490,10 +491,10 @@ font_family: Arial`,
},
{
name: "array data",
- content: `- filename: logo.png
+ content: `- key: logo.png
content_type: image/png
url: https://example.com/logo.png
-- filename: style.css
+- key: style.css
content_type: text/css
url: https://example.com/style.css`,
wantErr: false,
@@ -504,7 +505,7 @@ font_family: Arial`,
first, ok := arr[0].(map[string]interface{})
require.True(t, ok)
- assert.Equal(t, "logo.png", first["filename"])
+ assert.Equal(t, "logo.png", first["key"])
},
},
{
@@ -612,12 +613,14 @@ func TestDeserializer_AssetsTypeAssertion(t *testing.T) {
testutil.WriteTestFile(t, filepath.Join(themePath, "theme.yml"), string(data))
// Write assets.json
- assets := `- filename: logo.png
+ assets := `- key: logo.png
content_type: image/png
url: https://example.com/logo.png
-- filename: style.css
+ content_hash: hash1
+- key: style.css
content_type: text/css
- url: https://example.com/style.css`
+ url: https://example.com/style.css
+ content_hash: hash2`
testutil.WriteTestFile(t, filepath.Join(themePath, "assets.json"), assets)
deserializer := NewDeserializer(basePath)
@@ -625,10 +628,11 @@ func TestDeserializer_AssetsTypeAssertion(t *testing.T) {
require.NoError(t, err)
require.Len(t, theme.Assets, 2)
- assert.Equal(t, "logo.png", theme.Assets[0].Filename)
+ assert.Equal(t, "logo.png", theme.Assets[0].Key)
assert.Equal(t, "image/png", theme.Assets[0].ContentType)
assert.Equal(t, "https://example.com/logo.png", theme.Assets[0].URL)
- assert.Equal(t, "style.css", theme.Assets[1].Filename)
+ assert.Equal(t, "hash1", theme.Assets[0].ContentHash)
+ assert.Equal(t, "style.css", theme.Assets[1].Key)
}
func TestDeserializer_InvalidAssetsFormat(t *testing.T) {
diff --git a/internal/theme/serializer.go b/internal/theme/serializer.go
index c5014a3..f035588 100644
--- a/internal/theme/serializer.go
+++ b/internal/theme/serializer.go
@@ -2,21 +2,65 @@ package theme
import (
"fmt"
+ "io"
+ "net/http"
"os"
"path/filepath"
+ "time"
"github.com/GetStoreConnect/storeconnect-cli/internal/api"
"gopkg.in/yaml.v3"
)
+// Downloader fetches the bytes at a URL. It is a seam so asset downloading can
+// be driven by httptest (or stubbed) without reaching the network.
+type Downloader func(url string) ([]byte, error)
+
+// defaultDownloader fetches a URL over HTTP with a sane timeout.
+func defaultDownloader(url string) ([]byte, error) {
+ client := &http.Client{Timeout: 60 * time.Second}
+ resp, err := client.Get(url)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("download failed with status %d", resp.StatusCode)
+ }
+
+ return io.ReadAll(resp.Body)
+}
+
// Serializer converts API theme JSON to local file structure
type Serializer struct {
basePath string
+
+ // download fetches asset binaries; overridable for tests.
+ download Downloader
+ // warn reports a non-fatal problem (e.g. a failed asset download).
+ warn func(string)
}
// NewSerializer creates a new theme serializer
func NewSerializer(basePath string) *Serializer {
- return &Serializer{basePath: basePath}
+ return &Serializer{
+ basePath: basePath,
+ download: defaultDownloader,
+ warn: func(msg string) { fmt.Fprintln(os.Stderr, msg) },
+ }
+}
+
+// WithDownloader overrides how asset binaries are fetched (used in tests).
+func (s *Serializer) WithDownloader(d Downloader) *Serializer {
+ s.download = d
+ return s
+}
+
+// WithWarner overrides where non-fatal warnings are reported (used in tests).
+func (s *Serializer) WithWarner(w func(string)) *Serializer {
+ s.warn = w
+ return s
}
// Serialize writes a theme to the local filesystem
@@ -43,11 +87,15 @@ func (s *Serializer) Serialize(theme *api.Theme) error {
return fmt.Errorf("failed to write variables: %w", err)
}
- // Write assets.json
+ // Write assets.json metadata
if err := s.writeJSON(filepath.Join(themePath, "assets.json"), theme.Assets); err != nil {
return fmt.Errorf("failed to write assets: %w", err)
}
+ // Download asset binaries into assets/. A failed download is a warning,
+ // not a fatal error - the rest of the theme is still usable.
+ s.downloadAssets(themePath, theme.Assets)
+
return nil
}
@@ -93,6 +141,35 @@ func (s *Serializer) writeTemplates(themePath string, templates []api.ThemeTempl
return nil
}
+// downloadAssets fetches each asset's URL into assets/, creating any
+// subdirectories the key implies. Download failures are reported via warn and
+// skipped so a single broken asset never fails the whole pull.
+func (s *Serializer) downloadAssets(themePath string, assets []api.ThemeAsset) {
+ for _, asset := range assets {
+ if asset.Key == "" || asset.URL == "" {
+ continue
+ }
+
+ content, err := s.download(asset.URL)
+ if err != nil {
+ s.warn(fmt.Sprintf("warning: failed to download asset %q: %v", asset.Key, err))
+ continue
+ }
+
+ // Key may contain slashes - build the nested path and ensure dirs exist.
+ destPath := filepath.Join(themePath, AssetsDirName, filepath.FromSlash(asset.Key))
+ if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
+ s.warn(fmt.Sprintf("warning: failed to create directory for asset %q: %v", asset.Key, err))
+ continue
+ }
+
+ if err := os.WriteFile(destPath, content, 0644); err != nil {
+ s.warn(fmt.Sprintf("warning: failed to write asset %q: %v", asset.Key, err))
+ continue
+ }
+ }
+}
+
func (s *Serializer) writeJSON(path string, data interface{}) error {
if data == nil {
return nil
diff --git a/internal/theme/serializer_test.go b/internal/theme/serializer_test.go
index 1a333e1..a471e9d 100644
--- a/internal/theme/serializer_test.go
+++ b/internal/theme/serializer_test.go
@@ -20,6 +20,75 @@ func TestNewSerializer(t *testing.T) {
assert.Equal(t, basePath, serializer.basePath)
}
+func TestSerializer_DownloadsAssets(t *testing.T) {
+ basePath, cleanup := testutil.CreateTempProject(t)
+ defer cleanup()
+
+ theme := &api.Theme{
+ SCID: "with-assets",
+ Name: "With Assets",
+ Assets: []api.ThemeAsset{
+ {Key: "images/logo.png", URL: "https://example.com/logo.png"},
+ {Key: "style.css", URL: "https://example.com/style.css"},
+ },
+ }
+
+ requested := map[string]bool{}
+ serializer := NewSerializer(basePath).WithDownloader(func(url string) ([]byte, error) {
+ requested[url] = true
+ return []byte("downloaded:" + url), nil
+ })
+
+ require.NoError(t, serializer.Serialize(theme))
+
+ themePath := filepath.Join(basePath, "themes", theme.Name)
+
+ // Nested key creates subdirectories under assets/.
+ logoPath := filepath.Join(themePath, "assets", "images", "logo.png")
+ assert.True(t, testutil.FileExists(logoPath))
+ assert.Equal(t, "downloaded:https://example.com/logo.png", testutil.ReadTestFile(t, logoPath))
+
+ cssPath := filepath.Join(themePath, "assets", "style.css")
+ assert.True(t, testutil.FileExists(cssPath))
+
+ assert.True(t, requested["https://example.com/logo.png"])
+ assert.True(t, requested["https://example.com/style.css"])
+}
+
+func TestSerializer_DownloadErrorIsWarnedNotFatal(t *testing.T) {
+ basePath, cleanup := testutil.CreateTempProject(t)
+ defer cleanup()
+
+ theme := &api.Theme{
+ SCID: "broken-asset",
+ Name: "Broken Asset",
+ Assets: []api.ThemeAsset{
+ {Key: "ok.png", URL: "https://example.com/ok.png"},
+ {Key: "bad.png", URL: "https://example.com/bad.png"},
+ },
+ }
+
+ var warnings []string
+ serializer := NewSerializer(basePath).
+ WithWarner(func(msg string) { warnings = append(warnings, msg) }).
+ WithDownloader(func(url string) ([]byte, error) {
+ if url == "https://example.com/bad.png" {
+ return nil, assert.AnError
+ }
+ return []byte("ok"), nil
+ })
+
+ // A failed download must not fail the whole serialize.
+ require.NoError(t, serializer.Serialize(theme))
+
+ themePath := filepath.Join(basePath, "themes", theme.Name)
+ assert.True(t, testutil.FileExists(filepath.Join(themePath, "assets", "ok.png")))
+ assert.False(t, testutil.FileExists(filepath.Join(themePath, "assets", "bad.png")))
+
+ require.Len(t, warnings, 1)
+ assert.Contains(t, warnings[0], "bad.png")
+}
+
func TestSerializer_Serialize(t *testing.T) {
tests := []struct {
name string
@@ -155,7 +224,8 @@ func TestSerializer_Serialize(t *testing.T) {
basePath, cleanup := testutil.CreateTempProject(t)
defer cleanup()
- serializer := NewSerializer(basePath)
+ serializer := NewSerializer(basePath).
+ WithDownloader(func(url string) ([]byte, error) { return []byte("stub-asset"), nil })
err := serializer.Serialize(tt.theme)
if tt.wantErr {
@@ -364,8 +434,8 @@ func TestSerializer_writeJSON(t *testing.T) {
{
name: "array data",
data: []api.ThemeAsset{
- {Filename: "logo.png", ContentType: "image/png", URL: "https://example.com/logo.png"},
- {Filename: "style.css", ContentType: "text/css", URL: "https://example.com/style.css"},
+ {Key: "logo.png", ContentType: "image/png", URL: "https://example.com/logo.png", ContentHash: "hash1"},
+ {Key: "style.css", ContentType: "text/css", URL: "https://example.com/style.css", ContentHash: "hash2"},
},
wantErr: false,
validate: func(t *testing.T, path string, data interface{}) {
@@ -378,8 +448,8 @@ func TestSerializer_writeJSON(t *testing.T) {
require.NoError(t, err)
assert.Len(t, result, 2)
- assert.Equal(t, "logo.png", result[0]["filename"])
- assert.Equal(t, "style.css", result[1]["filename"])
+ assert.Equal(t, "logo.png", result[0]["key"])
+ assert.Equal(t, "style.css", result[1]["key"])
},
},
{
@@ -487,7 +557,8 @@ func TestSerializer_RoundTrip(t *testing.T) {
original := testutil.TestTheme()
// Serialize
- serializer := NewSerializer(basePath)
+ serializer := NewSerializer(basePath).
+ WithDownloader(func(url string) ([]byte, error) { return []byte("stub-asset"), nil })
err := serializer.Serialize(original)
require.NoError(t, err)