├── internal ├── config │ ├── testdata │ │ └── .gitkeep │ ├── config_test.go │ ├── config.go │ └── shop.go ├── engine │ ├── testdata │ │ ├── empty.json │ │ ├── product.json │ │ ├── variants.json │ │ └── media.json │ ├── restore.go │ ├── engine_test.go │ ├── engine.go │ ├── backup.go │ ├── resource.go │ └── backup_test.go ├── registry │ ├── testdata │ │ └── bkp │ │ │ └── products │ │ │ └── 2025 │ │ │ └── 01 │ │ │ └── eg │ │ │ └── 8737843216608 │ │ │ └── product.json │ ├── registry_test.go │ └── file_test.go ├── runner │ ├── backup │ │ ├── product │ │ │ ├── provider │ │ │ │ ├── product.go │ │ │ │ ├── variant.go │ │ │ │ ├── media.go │ │ │ │ └── metafields.go │ │ │ └── product.go │ │ └── customer │ │ │ ├── provider │ │ │ ├── customer.go │ │ │ └── metafields.go │ │ │ └── customer.go │ ├── runner.go │ └── restore │ │ ├── product │ │ └── handler │ │ │ ├── media.go │ │ │ └── metafields.go │ │ └── customer │ │ ├── handler │ │ └── metafields.go │ │ └── customer.go ├── version │ ├── default.go │ └── version.go ├── cmd │ ├── version │ │ └── version.go │ ├── auth │ │ ├── auth.go │ │ └── login │ │ │ └── login.go │ ├── config │ │ ├── current-context │ │ │ └── current_context.go │ │ ├── config.go │ │ ├── use-context │ │ │ └── use_context.go │ │ ├── get-contexts │ │ │ └── get_contexts.go │ │ └── delete-context │ │ │ └── delete_context.go │ ├── product │ │ ├── delete │ │ │ └── delete.go │ │ ├── media │ │ │ ├── media.go │ │ │ ├── attach │ │ │ │ └── attach.go │ │ │ └── detach │ │ │ │ └── detach.go │ │ ├── option │ │ │ ├── option.go │ │ │ ├── remove │ │ │ │ └── remove.go │ │ │ └── add │ │ │ │ └── add.go │ │ ├── variant │ │ │ ├── variant.go │ │ │ └── remove │ │ │ │ └── remove.go │ │ ├── product.go │ │ ├── peek │ │ │ └── peek.go │ │ └── create │ │ │ └── create.go │ ├── root │ │ ├── root.go │ │ └── help.go │ ├── webhook │ │ ├── unsubscribe │ │ │ └── unsubscribe.go │ │ ├── webhook.go │ │ └── subscribe │ │ │ └── subscribe.go │ ├── order │ │ └── order.go │ └── customer │ │ ├── customer.go │ │ └── delete │ │ └── delete.go ├── api │ ├── order.go │ ├── errors.go │ ├── client.go │ └── metafields.go ├── oauth │ ├── templates │ │ └── success.html │ └── oauth_test.go └── cmdutil │ └── tui_test.go ├── .dockerignore ├── examples ├── pipeline │ └── product-enrichment │ │ ├── scripts │ │ ├── requirements.txt │ │ ├── enrich_products.py │ │ └── review_catalog.py │ │ ├── shopctl │ │ └── .shopconfig.yml │ │ └── .github │ │ └── workflows │ │ └── actions │ │ └── setup-shopctl │ │ └── action.yml └── scripts │ ├── weekly_customer_discounts.sh │ ├── discount_high_inventory.sh │ └── validate_recent_products.sh ├── .github ├── assets │ └── demo.gif └── workflows │ ├── ci.yml │ └── docker.yml ├── .gitignore ├── schema ├── error.go └── unmarshler.go ├── pkg ├── fmtout │ ├── doc.go │ └── csv.go ├── gql │ ├── client │ │ ├── testdata │ │ │ ├── shop.json │ │ │ └── error.json │ │ ├── doc.go │ │ └── client_test.go │ └── introspect │ │ ├── doc.go │ │ └── ops.go ├── tui │ └── table │ │ ├── table.go │ │ ├── static.go │ │ └── interactive.go ├── browser │ ├── browser_test.go │ └── browser.go ├── tlog │ ├── doc.go │ └── log.go └── search │ ├── doc.go │ ├── search_test.go │ └── search.go ├── cmd ├── shopctl │ └── main.go └── introspect │ └── query.go ├── Dockerfile ├── .golangci.yml ├── app_test.go ├── Makefile ├── app.go └── go.mod /internal/config/testdata/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | .gocache/ 3 | -------------------------------------------------------------------------------- /internal/engine/testdata/empty.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /examples/pipeline/product-enrichment/scripts/requirements.txt: -------------------------------------------------------------------------------- 1 | openai 2 | -------------------------------------------------------------------------------- /.github/assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankitpokhrel/shopctl/main/.github/assets/demo.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.out 3 | *.swp 4 | 5 | .DS_Store 6 | .idea/ 7 | .vscode/ 8 | .gocache/ 9 | 10 | /dist 11 | /bin 12 | /coverage 13 | /vendor 14 | -------------------------------------------------------------------------------- /examples/pipeline/product-enrichment/shopctl/.shopconfig.yml: -------------------------------------------------------------------------------- 1 | ver: v0 2 | contexts: 3 | - alias: store1 4 | store: store1.myshopify.com 5 | currentContext: store1 6 | -------------------------------------------------------------------------------- /internal/engine/testdata/product.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "gid://shopify/Product/8737843216608", 3 | "title": "Test Product", 4 | "createdAt": "2024-11-03T16:36:15Z", 5 | "totalInventory": 50 6 | } 7 | -------------------------------------------------------------------------------- /internal/registry/testdata/bkp/products/2025/01/eg/8737843216608/product.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "gid://shopify/Product/8737843216608", 3 | "title": "Test Product", 4 | "createdAt": "2024-11-03T16:36:15Z", 5 | "totalInventory": 50 6 | } 7 | -------------------------------------------------------------------------------- /schema/error.go: -------------------------------------------------------------------------------- 1 | // Code generated by introspect; EDIT WITH CAUTION. 2 | 3 | package schema 4 | 5 | type UserError struct { 6 | Field []any `json:"field"` 7 | Message string `json:"message"` 8 | Code string `json:"code"` 9 | } 10 | -------------------------------------------------------------------------------- /pkg/fmtout/doc.go: -------------------------------------------------------------------------------- 1 | // Package fmtout provides utilities for formatting and emitting data 2 | // in various output formats such as CSV, TSV, etc. 3 | // 4 | // Example: 5 | // 6 | // f := fmtout.NewFormatter(cols, rows) 7 | // err := f.Format(os.Stdout) 8 | package fmtout 9 | -------------------------------------------------------------------------------- /internal/runner/backup/product/provider/product.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import "github.com/ankitpokhrel/shopctl/schema" 4 | 5 | type Product struct { 6 | Product *schema.Product 7 | } 8 | 9 | func (p *Product) Handle(_ any) (any, error) { 10 | return p.Product, nil 11 | } 12 | -------------------------------------------------------------------------------- /internal/runner/backup/customer/provider/customer.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import "github.com/ankitpokhrel/shopctl/schema" 4 | 5 | type Customer struct { 6 | Customer *schema.Customer 7 | } 8 | 9 | func (c *Customer) Handle(_ any) (any, error) { 10 | return c.Customer, nil 11 | } 12 | -------------------------------------------------------------------------------- /cmd/shopctl/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/ankitpokhrel/shopctl/internal/cmd/root" 8 | ) 9 | 10 | func main() { 11 | rootCmd := root.NewCmdRoot() 12 | if err := rootCmd.Execute(); err != nil { 13 | fmt.Fprintf(os.Stderr, "%s\n", err) 14 | os.Exit(1) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /internal/engine/testdata/variants.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "gid://shopify/Product/8737843216608", 3 | "variants": { 4 | "edges": [ 5 | { 6 | "node": { 7 | "availableForSale": true, 8 | "createdAt": "2024-04-12T18:27:08Z", 9 | "displayName": "Test Product" 10 | } 11 | } 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pkg/gql/client/testdata/shop.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "shop": { 4 | "name": "shopify" 5 | } 6 | }, 7 | "extensions": { 8 | "cost": { 9 | "requestedQueryCost": 1, 10 | "actualQueryCost": 1, 11 | "throttleStatus": { 12 | "maximumAvailable": 2000, 13 | "currentlyAvailable": 1999, 14 | "restoreRate": 100 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /internal/version/default.go: -------------------------------------------------------------------------------- 1 | //go:build go1.12 2 | 3 | package version 4 | 5 | import "runtime/debug" 6 | 7 | func init() { 8 | if info, ok := debug.ReadBuildInfo(); ok { 9 | // info.Main.Version describes the version of the module containing 10 | // package main, not the version of “the main module”. 11 | // See https://golang.org/issue/33975. 12 | if Version == "v0.0.0-dev" && info.Main.Version != "(devel)" { 13 | Version = info.Main.Version 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /pkg/gql/client/testdata/error.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors": [ 3 | { 4 | "message": "Field 'names' doesn't exist on type 'Shop'", 5 | "locations": [ 6 | { 7 | "line": 3, 8 | "column": 3 9 | } 10 | ], 11 | "path": [ 12 | "query", 13 | "shop", 14 | "names" 15 | ], 16 | "extensions": { 17 | "code": "undefinedField", 18 | "typeName": "Shop", 19 | "fieldName": "names" 20 | } 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /pkg/tui/table/table.go: -------------------------------------------------------------------------------- 1 | package table 2 | 3 | // RowsToString type casts `[]Row` to `[][]string`. 4 | func RowsToString(rows []Row) [][]string { 5 | result := make([][]string, len(rows)) 6 | for i, row := range rows { 7 | result[i] = []string(row) 8 | } 9 | return result 10 | } 11 | 12 | // ColsToString type casts `[]Column` to `[]string`. 13 | func ColsToString(cols []Column) []string { 14 | result := make([]string, len(cols)) 15 | for i, col := range cols { 16 | result[i] = col.Title 17 | } 18 | return result 19 | } 20 | -------------------------------------------------------------------------------- /examples/pipeline/product-enrichment/.github/workflows/actions/setup-shopctl/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup ShopCTL 2 | description: Installs Go and ShopCTL CLI 3 | runs: 4 | using: "composite" 5 | steps: 6 | - name: Set up Go 7 | uses: actions/setup-go@v5 8 | with: 9 | go-version: "1.24" 10 | 11 | - name: Install ShopCTL 12 | shell: bash 13 | run: | 14 | sudo apt-get update 15 | sudo apt-get install -y libx11-dev 16 | go install github.com/ankitpokhrel/shopctl/cmd/shopctl@main 17 | echo "$HOME/go/bin" >> "$GITHUB_PATH" 18 | -------------------------------------------------------------------------------- /internal/engine/restore.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Restore is a restore engine. 8 | type Restore struct { 9 | store string 10 | timestamp time.Time 11 | } 12 | 13 | // NewRestore creates a new restore engine. 14 | func NewRestore(store string) *Restore { 15 | return &Restore{ 16 | store: store, 17 | timestamp: time.Now(), 18 | } 19 | } 20 | 21 | // Do starts the restoration process. 22 | // Implements `engine.Doer` interface. 23 | func (r *Restore) Do(rs Resource, data any) (any, error) { 24 | return rs.Handler.Handle(data) 25 | } 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Usage: 2 | # $ docker build -t shopctl:latest . 3 | # $ docker run --rm -it -v ~/.config/shopctl:/root/.config/shopctl shopctl 4 | 5 | FROM golang:1.24-alpine3.21 as builder 6 | 7 | ENV CGO_ENABLED=0 8 | ENV GOOS=linux 9 | 10 | WORKDIR /app 11 | 12 | COPY . . 13 | 14 | RUN set -eux; \ 15 | env ; \ 16 | ls -la ; \ 17 | apk add -U --no-cache make git ; \ 18 | make deps install 19 | 20 | FROM alpine:3.21 21 | 22 | RUN apk --no-cache add ca-certificates 23 | 24 | WORKDIR /root/ 25 | 26 | COPY --from=builder /go/bin/shopctl /bin/shopctl 27 | 28 | ENTRYPOINT ["/bin/sh"] 29 | -------------------------------------------------------------------------------- /pkg/gql/client/doc.go: -------------------------------------------------------------------------------- 1 | // Package client provides a GraphQL client for making requests to a GraphQL server. 2 | // 3 | // Example usage: 4 | // 5 | // client := client.NewClient("https://example.com/graphql", "your-access-token") 6 | // headers := client.Header{ 7 | // "Custom-Header": "value", 8 | // } 9 | // body := []byte(`{ "query": "{ exampleQuery { field } }" }`) 10 | // resp, err := client.Request(context.Background(), body, headers) 11 | // if err != nil { 12 | // log.Fatalf("request error: %v", err) 13 | // } 14 | // defer resp.Body.Close() 15 | // // Process response... 16 | package client 17 | -------------------------------------------------------------------------------- /internal/cmd/version/version.go: -------------------------------------------------------------------------------- 1 | // Package version prints the version information of the tool. 2 | package version 3 | 4 | import ( 5 | "fmt" 6 | 7 | v "github.com/ankitpokhrel/shopctl/internal/version" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // NewCmdVersion is a version command. 13 | func NewCmdVersion() *cobra.Command { 14 | return &cobra.Command{ 15 | Use: "version", 16 | Short: "Print the app version information", 17 | Long: "Print the app version and build information", 18 | Run: version, 19 | } 20 | } 21 | 22 | func version(*cobra.Command, []string) { 23 | fmt.Println(v.Info()) 24 | } 25 | -------------------------------------------------------------------------------- /schema/unmarshler.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "encoding/json" 5 | "strconv" 6 | ) 7 | 8 | func (m *MoneyV2) UnmarshalJSON(data []byte) error { 9 | var raw map[string]any 10 | if err := json.Unmarshal(data, &raw); err != nil { 11 | return err 12 | } 13 | m.CurrencyCode = CurrencyCode(raw["currencyCode"].(string)) 14 | 15 | if val, isFloat := raw["amount"].(float64); isFloat { 16 | m.Amount = val 17 | } else { 18 | if val, ok := raw["amount"]; ok { 19 | amount, err := strconv.ParseFloat(val.(string), 64) 20 | if err != nil { 21 | return err 22 | } 23 | m.Amount = amount 24 | } 25 | } 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /pkg/gql/introspect/doc.go: -------------------------------------------------------------------------------- 1 | // Package introspect provides utilities for converting GraphQL introspection schemas 2 | // into Go type definitions. It includes functionality to handle various GraphQL types 3 | // such as OBJECT, INPUT_OBJECT, INTERFACE and ENUM, and generate corresponding Go code. 4 | // The support for other types will be added as needed. 5 | // 6 | // This intention of this package is to generate Go code from GraphQL introspection schemas 7 | // in case the public GraphQL schema is not available for whatever reason. 8 | // 9 | // See https://github.com/facebook/graphql/blob/master/spec/Section%204%20--%20Introspection.md 10 | package introspect 11 | -------------------------------------------------------------------------------- /internal/runner/backup/product/provider/variant.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "github.com/ankitpokhrel/shopctl/internal/api" 5 | "github.com/ankitpokhrel/shopctl/pkg/tlog" 6 | ) 7 | 8 | type Variant struct { 9 | Client *api.GQLClient 10 | Logger *tlog.Logger 11 | ProductID string 12 | } 13 | 14 | func (v *Variant) Handle(_ any) (any, error) { 15 | v.Logger.Infof("Product %s: processing variants", v.ProductID) 16 | 17 | variants, err := v.Client.GetProductVariants(v.ProductID) 18 | if err != nil { 19 | v.Logger.Error("Error when fetching variants", "productId", v.ProductID, "error", err) 20 | return nil, err 21 | } 22 | return variants, nil 23 | } 24 | -------------------------------------------------------------------------------- /internal/runner/backup/product/provider/media.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "github.com/ankitpokhrel/shopctl/internal/api" 5 | "github.com/ankitpokhrel/shopctl/pkg/tlog" 6 | ) 7 | 8 | type Media struct { 9 | Client *api.GQLClient 10 | Logger *tlog.Logger 11 | ProductID string 12 | } 13 | 14 | func (m *Media) Handle(_ any) (any, error) { 15 | m.Logger.Infof("Product %s: processing media items", m.ProductID) 16 | 17 | medias, err := m.Client.GetProductMedias(m.ProductID) 18 | if err != nil { 19 | m.Logger.Error("Error when fetching media", "productID", m.ProductID, "error", err) 20 | return nil, err 21 | } 22 | return medias.Data.Product, nil 23 | } 24 | -------------------------------------------------------------------------------- /internal/runner/backup/product/provider/metafields.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "github.com/ankitpokhrel/shopctl/internal/api" 5 | "github.com/ankitpokhrel/shopctl/pkg/tlog" 6 | ) 7 | 8 | type MetaField struct { 9 | Client *api.GQLClient 10 | Logger *tlog.Logger 11 | ProductID string 12 | } 13 | 14 | func (m *MetaField) Handle(_ any) (any, error) { 15 | m.Logger.Infof("Product %s: processing meta fields", m.ProductID) 16 | 17 | metafields, err := m.Client.GetProductMetaFields(m.ProductID) 18 | if err != nil { 19 | m.Logger.Error("Error when fetching metafield", "productID", m.ProductID, "error", err) 20 | return nil, err 21 | } 22 | return metafields.Data.Product, nil 23 | } 24 | -------------------------------------------------------------------------------- /internal/runner/backup/customer/provider/metafields.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "github.com/ankitpokhrel/shopctl/internal/api" 5 | "github.com/ankitpokhrel/shopctl/pkg/tlog" 6 | ) 7 | 8 | type MetaField struct { 9 | Client *api.GQLClient 10 | Logger *tlog.Logger 11 | CustomerID string 12 | } 13 | 14 | func (m *MetaField) Handle(_ any) (any, error) { 15 | m.Logger.Infof("Customer %s: processing meta fields", m.CustomerID) 16 | 17 | metafields, err := m.Client.GetCustomerMetaFields(m.CustomerID) 18 | if err != nil { 19 | m.Logger.Error("Error when fetching metafield", "customerID", m.CustomerID, "error", err) 20 | return nil, err 21 | } 22 | return metafields.Data.Customer, nil 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | types: [opened, synchronize, reopened] 4 | release: 5 | types: [published] 6 | push: 7 | branches: [main] 8 | 9 | name: Build 10 | 11 | jobs: 12 | tests: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Install X11 development libraries 19 | run: | 20 | sudo apt-get update 21 | sudo apt-get install -y libx11-dev 22 | 23 | - name: Setup Go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: '^1.24.1' 27 | 28 | - name: Install dependencies 29 | run: make deps 30 | 31 | - name: Run linter 32 | run: make lint 33 | 34 | - name: Run tests 35 | run: make test 36 | -------------------------------------------------------------------------------- /internal/runner/runner.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ankitpokhrel/shopctl/internal/engine" 7 | ) 8 | 9 | // Runner is a runner interface. 10 | type Runner interface { 11 | Run() error 12 | Kind() engine.ResourceType 13 | Stats() map[engine.ResourceType]*Summary 14 | } 15 | 16 | // RestoreFilter holds filter expressions for restore operation. 17 | type RestoreFilter struct { 18 | Filters map[string][]string 19 | Separators []string 20 | } 21 | 22 | // Summary aggregate runner stats. 23 | type Summary struct { 24 | Count int 25 | Passed int 26 | Failed int 27 | Skipped int 28 | } 29 | 30 | // String implements `fmt.Stringer` interface. 31 | // TODO: Skipped metrics. 32 | func (s Summary) String() string { 33 | return fmt.Sprintf(`Processed: %d 34 | Succeeded: %d 35 | Skipped: %d 36 | Failed: %d`, 37 | s.Count, s.Passed, 38 | s.Skipped, s.Failed, 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /internal/cmd/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/ankitpokhrel/shopctl/internal/cmd/auth/login" 7 | ) 8 | 9 | // NewCmdAuth is an auth command. 10 | func NewCmdAuth() *cobra.Command { 11 | cmd := cobra.Command{ 12 | Use: "auth", 13 | Short: "Initiate authentication request", 14 | Long: "Initiate authentication request to the Shopify host.", 15 | Annotations: map[string]string{"cmd:main": "true"}, 16 | RunE: auth, 17 | } 18 | 19 | cmd.PersistentFlags().StringP("store", "s", "", "Shopify store to login to") 20 | 21 | cmd.AddCommand( 22 | login.NewCmdLogin(), 23 | ) 24 | 25 | cmd.SetHelpFunc(func(c *cobra.Command, s []string) { 26 | _ = c.Flags().MarkHidden("context") 27 | c.Root().HelpFunc()(c, s) 28 | }) 29 | 30 | return &cmd 31 | } 32 | 33 | func auth(cmd *cobra.Command, _ []string) error { 34 | return cmd.Help() 35 | } 36 | -------------------------------------------------------------------------------- /internal/registry/registry_test.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetProductByID(t *testing.T) { 10 | pid := "8737843216608" 11 | path := "./testdata/bkp/" 12 | 13 | reg, err := NewRegistry("invalid") 14 | assert.NotNil(t, err) 15 | assert.Nil(t, reg) 16 | 17 | reg, err = NewRegistry(path) 18 | assert.NoError(t, err) 19 | 20 | product, err := reg.GetProductByID(pid) 21 | assert.NoError(t, err) 22 | assert.Equal(t, "gid://shopify/Product/8737843216608", product.ID) 23 | 24 | product, err = reg.GetProductByID("invalid") 25 | assert.Error(t, err) 26 | assert.Nil(t, product) 27 | 28 | reg, err = NewRegistry("./testdata/") 29 | assert.NoError(t, err) 30 | 31 | // This should fail because the file is located above the max depth. 32 | product, err = reg.GetProductByID(pid) 33 | assert.Error(t, err) 34 | assert.Nil(t, product) 35 | } 36 | -------------------------------------------------------------------------------- /cmd/introspect/query.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | func getQuery(gqlType string) map[string]string { 6 | query := fmt.Sprintf(`{ 7 | __type(name: "%s") { 8 | name 9 | kind 10 | description 11 | fields { 12 | name 13 | description 14 | type { 15 | name 16 | kind 17 | ofType { 18 | name 19 | kind 20 | } 21 | } 22 | args { 23 | name 24 | description 25 | type { 26 | name 27 | kind 28 | ofType { 29 | name 30 | kind 31 | } 32 | } 33 | defaultValue 34 | } 35 | } 36 | inputFields { 37 | name 38 | description 39 | type { 40 | name 41 | kind 42 | ofType { 43 | name 44 | kind 45 | } 46 | } 47 | } 48 | enumValues { 49 | name 50 | description 51 | } 52 | } 53 | }`, gqlType) 54 | 55 | return map[string]string{ 56 | "query": query, 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /internal/cmd/config/current-context/current_context.go: -------------------------------------------------------------------------------- 1 | package currentcontext 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 9 | "github.com/ankitpokhrel/shopctl/internal/config" 10 | ) 11 | 12 | const helpText = `Display the current-context.` 13 | 14 | // NewCmdCurrentContext is a cmd to display current context. 15 | func NewCmdCurrentContext() *cobra.Command { 16 | return &cobra.Command{ 17 | Use: "current-context", 18 | Short: "Display the current-context", 19 | Long: helpText, 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | cmdutil.ExitOnErr(run(cmd, args)) 22 | return nil 23 | }, 24 | } 25 | } 26 | 27 | func run(_ *cobra.Command, _ []string) error { 28 | cfg, err := config.NewShopConfig() 29 | if err != nil { 30 | return err 31 | } 32 | 33 | ctx := cfg.CurrentContext() 34 | if ctx == "" { 35 | return fmt.Errorf("current-context is not set") 36 | } 37 | 38 | fmt.Println(ctx) 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /internal/engine/testdata/media.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "gid://shopify/Product/8737843216608", 3 | "media": { 4 | "edges": [ 5 | { 6 | "node": { 7 | "id": "gid://shopify/MediaImage/33201292214520", 8 | "status": "READY", 9 | "preview": { 10 | "image": { 11 | "altText": "Test Media", 12 | "height": 1600, 13 | "metafield": null, 14 | "metafields": { 15 | "edges": null, 16 | "nodes": null, 17 | "pageInfo": { "hasNextPage": false, "hasPreviousPage": false } 18 | }, 19 | "url": "https://cdn.shopify.com/s/files/1/0695/7373/8744/files/Main_b13ad453-477c-4ed1-9b43-81f3345adfd6.jpg?v=1712946428", 20 | "width": 1600 21 | }, 22 | "status": "READY" 23 | }, 24 | "mediaContentType": "IMAGE", 25 | "mediaErrors": [], 26 | "mediaWarnings": [] 27 | } 28 | } 29 | ] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: none 4 | enable: 5 | - bodyclose 6 | - dupl 7 | - errcheck 8 | - gocritic 9 | - gocyclo 10 | - godot 11 | - govet 12 | - ineffassign 13 | - mnd 14 | - prealloc 15 | - revive 16 | - staticcheck 17 | - unparam 18 | - unused 19 | settings: 20 | gocyclo: 21 | min-complexity: 20 22 | mnd: 23 | ignored-numbers: 24 | - "0" 25 | - "1" 26 | - "2" 27 | - "3" 28 | revive: 29 | rules: 30 | - name: package-comments 31 | disabled: true 32 | testpackage: 33 | skip-regexp: .* 34 | exclusions: 35 | generated: lax 36 | paths: 37 | - third_party$ 38 | - builtin$ 39 | - examples$ 40 | formatters: 41 | enable: 42 | - gofmt 43 | - gofumpt 44 | - goimports 45 | settings: 46 | gofmt: 47 | simplify: false 48 | exclusions: 49 | generated: lax 50 | paths: 51 | - third_party$ 52 | - builtin$ 53 | - examples$ 54 | -------------------------------------------------------------------------------- /pkg/browser/browser_test.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestBrowserENVPrecedence(t *testing.T) { 10 | cases := []struct { 11 | name string 12 | setup func(t *testing.T) 13 | expected string 14 | }{ 15 | { 16 | name: "it uses SHOPIFY_BROWSER env", 17 | setup: func(t *testing.T) { 18 | t.Setenv("SHOPIFY_BROWSER", "firefox") 19 | }, 20 | expected: "firefox", 21 | }, 22 | { 23 | name: "it uses BROWSER env", 24 | setup: func(t *testing.T) { 25 | t.Setenv("BROWSER", "chrome") 26 | }, 27 | expected: "chrome", 28 | }, 29 | { 30 | name: "SHOPIFY_BROWSER gets precedence over BROWSER env if both are set", 31 | setup: func(t *testing.T) { 32 | t.Setenv("BROWSER", "chrome") 33 | t.Setenv("SHOPIFY_BROWSER", "firefox") 34 | }, 35 | expected: "firefox", 36 | }, 37 | } 38 | 39 | for _, tc := range cases { 40 | t.Run(tc.name, func(t *testing.T) { 41 | tc.setup(t) 42 | assert.Equal(t, tc.expected, getBrowserFromENV()) 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "strconv" 7 | "time" 8 | ) 9 | 10 | // Build information is populated at build-time. 11 | var ( 12 | Version = "v0.0.0-dev" 13 | GitCommit = "" 14 | SourceDateEpoch = "-1" 15 | GoVersion = runtime.Version() 16 | Compiler = runtime.Compiler 17 | Platform = fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) 18 | ) 19 | 20 | // Info returns version and build information. 21 | func Info() string { 22 | i, err := strconv.ParseInt(SourceDateEpoch, 10, 64) //nolint:gomnd 23 | if err != nil { 24 | panic(err) 25 | } 26 | commitDate := "" 27 | if i >= 0 { 28 | // https://pkg.go.dev/time#Time.Format 29 | // 30 | // $ TZ=MST date -Iseconds -d"Jan 2 15:04:05 2006 MST" 31 | // 2006-01-02T15:04:05-07:00 32 | commitDate = time.Unix(i, 0).UTC().Format("2006-01-02T15:04:05-07:00") 33 | } 34 | return fmt.Sprintf( 35 | "(Version=%q, GitCommit=%q, CommitDate=%q, GoVersion=%q, Compiler=%q, Platform=%q)", 36 | Version, GitCommit, commitDate, GoVersion, Compiler, Platform, 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/tlog/doc.go: -------------------------------------------------------------------------------- 1 | // Package tlog provides a console-based structured logger for terminal applications. 2 | // 3 | // This package uses `log/slog`, available since go1.21+, as a logging frontend, and 4 | // `uber-go/zap` as a logging backend. Technically, this package is simply a wrapper 5 | // around these two libraries to display text based structured logs in the terminal. 6 | // 7 | // The package offers different levels of verbosity, allowing developers to control 8 | // the amount of detail logged. The available verbosity levels (VerboseLevel) are: 9 | // 10 | // - VL0: Default verbosity. 11 | // - VL1: Minimum verbosity. 12 | // - VL2: Intermediate verbosity. 13 | // - VL3: Highest verbosity. 14 | // 15 | // Example Usage: 16 | // 17 | // logger := tlog.New(tlog.VL1) // VL1 is global VerboseLevel for this logger. 18 | // 19 | // logger.Info("Application started") // VL0 20 | // logger.Infof("Application started with pid: %d", pid) 21 | // logger.V(tlog.VL2).Warn("Failed to execute query", "error", err) // Only displayed if global VerboseLevel is 2. 22 | // 23 | // The package uses colored level encoder by default. 24 | package tlog 25 | -------------------------------------------------------------------------------- /internal/cmd/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | cc "github.com/ankitpokhrel/shopctl/internal/cmd/config/current-context" 7 | dc "github.com/ankitpokhrel/shopctl/internal/cmd/config/delete-context" 8 | gc "github.com/ankitpokhrel/shopctl/internal/cmd/config/get-contexts" 9 | uc "github.com/ankitpokhrel/shopctl/internal/cmd/config/use-context" 10 | ) 11 | 12 | const helpText = `Modify shopconfig files using commands like "shopctl config set-context my-context".` 13 | 14 | // NewCmdConfig creates a new config command. 15 | func NewCmdConfig() *cobra.Command { 16 | cmd := cobra.Command{ 17 | Use: "config", 18 | Short: "Modify shopconfig files", 19 | Long: helpText, 20 | Annotations: map[string]string{"cmd:main": "true"}, 21 | Aliases: []string{"cfg"}, 22 | RunE: config, 23 | } 24 | 25 | cmd.AddCommand( 26 | uc.NewCmdUseContext(), 27 | cc.NewCmdCurrentContext(), 28 | dc.NewCmdDeleteContext(), 29 | gc.NewCmdGetContexts(), 30 | ) 31 | 32 | return &cmd 33 | } 34 | 35 | func config(cmd *cobra.Command, _ []string) error { 36 | return cmd.Help() 37 | } 38 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestConfigHome(t *testing.T) { 12 | assert.NoError(t, os.Unsetenv("SHOPIFY_CONFIG_HOME")) 13 | assert.NoError(t, os.Unsetenv("XDG_CONFIG_HOME")) 14 | 15 | userHome, err := os.UserHomeDir() 16 | assert.NoError(t, err) 17 | assert.Equal(t, userHome+"/.config/shopctl", home()) 18 | 19 | t.Setenv("XDG_CONFIG_HOME", "./testdata") 20 | assert.Equal(t, "testdata/shopctl", home()) 21 | } 22 | 23 | func TestConfigSave(t *testing.T) { 24 | alias := "teststore" 25 | root := "./testdata/.tmp/shopctl" 26 | 27 | t.Setenv("SHOPIFY_CONFIG_HOME", "./testdata/.tmp/") 28 | 29 | c, _ := NewShopConfig() 30 | assert.NotNil(t, c) 31 | assert.NoError(t, c.Save()) 32 | 33 | assert.DirExists(t, root) 34 | assert.FileExists(t, fmt.Sprintf("%s/.shopconfig.yml", root)) 35 | assert.Equal(t, "v0", c.data.Version) 36 | assert.Equal(t, "", c.data.CurrentCtx) 37 | assert.Equal(t, "v0", c.writer.Get("ver")) 38 | assert.Equal(t, "", c.writer.Get("currentContext")) 39 | assert.Empty(t, GetToken(alias)) 40 | 41 | assert.NoError(t, os.RemoveAll("./testdata/.tmp/")) 42 | } 43 | -------------------------------------------------------------------------------- /internal/api/order.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/ankitpokhrel/shopctl/pkg/gql/client" 8 | "github.com/ankitpokhrel/shopctl/schema" 9 | ) 10 | 11 | // GetOrders fetches n number of orders after a cursor. 12 | func (c GQLClient) GetOrders(limit int, after *string, query *string) ([]schema.Order, error) { 13 | var out *OrdersResponse 14 | 15 | ordersQuery := fmt.Sprintf(`query GetOrders($first: Int!, $after: String, $query: String, $sortKey: OrderSortKeys!, $reverse: Boolean!) { 16 | orders(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) { 17 | nodes { 18 | %s 19 | } 20 | pageInfo { 21 | hasNextPage 22 | endCursor 23 | } 24 | } 25 | }`, fieldsOrder) 26 | 27 | req := client.GQLRequest{ 28 | Query: ordersQuery, 29 | Variables: client.QueryVars{ 30 | "first": limit, 31 | "after": after, 32 | "query": query, 33 | "sortKey": "PROCESSED_AT", 34 | "reverse": true, 35 | }, 36 | } 37 | if err := c.Execute(context.Background(), req, nil, &out); err != nil { 38 | return nil, err 39 | } 40 | if len(out.Errors) > 0 { 41 | return nil, fmt.Errorf("%s", out.Errors) 42 | } 43 | return out.Data.Orders.Nodes, nil 44 | } 45 | -------------------------------------------------------------------------------- /pkg/browser/browser.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "os/exec" 7 | 8 | "github.com/google/shlex" 9 | "github.com/pkg/browser" 10 | ) 11 | 12 | // Browse opens given url in a web browser. 13 | // 14 | // It looks for `SHOPIFY_BROWSER` and `BROWSER` env respectively to decide which 15 | // executable to use. If none of them are set, the default browser is invoked. 16 | func Browse(url string) error { 17 | opener := getBrowserFromENV() 18 | 19 | if opener == "" { 20 | // Launch default browser. 21 | return browser.OpenURL(url) 22 | } 23 | 24 | args, err := shlex.Split(opener) 25 | if err != nil { 26 | return err 27 | } 28 | exe, err := exec.LookPath(args[0]) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | args = append(args[1:], url) 34 | cmd := exec.Command(exe, args...) 35 | 36 | // io.Writer to which executed commands write standard output and error. 37 | // We are not interested in any output from cmd, so let's discard them. 38 | cmd.Stdout = &bytes.Buffer{} 39 | cmd.Stderr = &bytes.Buffer{} 40 | 41 | return cmd.Run() 42 | } 43 | 44 | func getBrowserFromENV() string { 45 | br := os.Getenv("SHOPIFY_BROWSER") 46 | if br == "" { 47 | br = os.Getenv("BROWSER") 48 | } 49 | return br 50 | } 51 | -------------------------------------------------------------------------------- /internal/cmd/config/use-context/use_context.go: -------------------------------------------------------------------------------- 1 | package usecontext 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 9 | "github.com/ankitpokhrel/shopctl/internal/config" 10 | ) 11 | 12 | const helpText = `Set current-context in the shopconfig file.` 13 | 14 | // NewCmdUseContext is a cmd to update current context. 15 | func NewCmdUseContext() *cobra.Command { 16 | return &cobra.Command{ 17 | Use: "use-context CONTEXT_NAME", 18 | Short: "Set current-context in the shopconfig file", 19 | Long: helpText, 20 | Aliases: []string{"use"}, 21 | Args: cobra.MinimumNArgs(1), 22 | RunE: func(cmd *cobra.Command, args []string) error { 23 | cmdutil.ExitOnErr(run(cmd, args)) 24 | return nil 25 | }, 26 | } 27 | } 28 | 29 | func run(_ *cobra.Command, args []string) error { 30 | cfg, err := config.NewShopConfig() 31 | if err != nil { 32 | return err 33 | } 34 | 35 | ctx := args[0] 36 | if len(ctx) == 0 { 37 | return fmt.Errorf("empty context names are not allowed") 38 | } 39 | 40 | if err := cfg.SetCurrentContext(ctx); err != nil { 41 | return err 42 | } 43 | if err := cfg.Save(); err != nil { 44 | return err 45 | } 46 | 47 | cmdutil.Success("Switched to context %q", ctx) 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /internal/cmd/product/delete/delete.go: -------------------------------------------------------------------------------- 1 | package delete 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/ankitpokhrel/shopctl" 7 | "github.com/ankitpokhrel/shopctl/internal/api" 8 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 9 | ) 10 | 11 | const ( 12 | helpText = `Delete lets you delete a product.` 13 | 14 | examples = `$ shopctl product delete 123456789 15 | $ shopctl product delete gid://shopify/Product/123456789` 16 | ) 17 | 18 | // NewCmdDelete constructs a new product delete command. 19 | func NewCmdDelete() *cobra.Command { 20 | return &cobra.Command{ 21 | Use: "delete PRODUCT_ID", 22 | Short: "Delete a product", 23 | Long: helpText, 24 | Example: examples, 25 | Args: cobra.MinimumNArgs(1), 26 | Aliases: []string{"del", "rm", "remove"}, 27 | Annotations: map[string]string{ 28 | "help:args": `PRODUCT_ID full or numeric Product ID, eg: 88561444456 or gid://shopify/Product/88561444456`, 29 | }, 30 | RunE: func(cmd *cobra.Command, args []string) error { 31 | client := cmd.Context().Value(cmdutil.KeyGQLClient).(*api.GQLClient) 32 | 33 | cmdutil.ExitOnErr(run(cmd, args, client)) 34 | return nil 35 | }, 36 | } 37 | } 38 | 39 | func run(_ *cobra.Command, args []string, client *api.GQLClient) error { 40 | productID := shopctl.ShopifyProductID(args[0]) 41 | 42 | _, err := client.DeleteProduct(productID) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | cmdutil.Success("Product deleted successfully") 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /app_test.go: -------------------------------------------------------------------------------- 1 | package shopctl 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestShopifyProductID(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | id string 13 | want string 14 | }{ 15 | { 16 | name: "valid shopify product id", 17 | id: "gid://shopify/Product/8737843085536", 18 | want: "gid://shopify/Product/8737843085536", 19 | }, 20 | { 21 | name: "numeric id", 22 | id: "8737843085536", 23 | want: "gid://shopify/Product/8737843085536", 24 | }, 25 | { 26 | name: "non numeric id", 27 | id: "invalid", 28 | want: "", 29 | }, 30 | { 31 | name: "invalid shopify product id", 32 | id: "gid://shopify/8737843085536", 33 | want: "", 34 | }, 35 | } 36 | for _, tc := range tests { 37 | t.Run(tc.name, func(t *testing.T) { 38 | assert.Equal(t, tc.want, ShopifyProductID(tc.id)) 39 | }) 40 | } 41 | } 42 | 43 | func TestExtractNumericID(t *testing.T) { 44 | tests := []struct { 45 | name string 46 | id string 47 | want string 48 | }{ 49 | { 50 | name: "shopify product id", 51 | id: "gid://shopify/Product/8737843085536", 52 | want: "8737843085536", 53 | }, 54 | { 55 | name: "shopify product variant id", 56 | id: "gid://shopify/ProductVariant/8737843085536", 57 | want: "8737843085536", 58 | }, 59 | } 60 | for _, tc := range tests { 61 | t.Run(tc.name, func(t *testing.T) { 62 | assert.Equal(t, tc.want, ExtractNumericID(tc.id)) 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /internal/cmd/root/root.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/ankitpokhrel/shopctl/internal/cmd/auth" 7 | "github.com/ankitpokhrel/shopctl/internal/cmd/config" 8 | "github.com/ankitpokhrel/shopctl/internal/cmd/customer" 9 | "github.com/ankitpokhrel/shopctl/internal/cmd/export" 10 | "github.com/ankitpokhrel/shopctl/internal/cmd/ingest" 11 | "github.com/ankitpokhrel/shopctl/internal/cmd/order" 12 | "github.com/ankitpokhrel/shopctl/internal/cmd/product" 13 | "github.com/ankitpokhrel/shopctl/internal/cmd/version" 14 | "github.com/ankitpokhrel/shopctl/internal/cmd/webhook" 15 | ) 16 | 17 | // NewCmdRoot constructs a root command. 18 | func NewCmdRoot() *cobra.Command { 19 | cmd := cobra.Command{ 20 | Use: "shopctl ", 21 | Short: "Manage Shopify data directly from your terminal", 22 | Long: "shopctl helps you manage Shopify data directly from your terminal.", 23 | RunE: func(cmd *cobra.Command, args []string) error { 24 | return cmd.Help() 25 | }, 26 | } 27 | 28 | cmd.PersistentFlags().StringP( 29 | "context", "c", "", 30 | "Override current-context", 31 | ) 32 | 33 | cmd.SetHelpFunc(helpFunc) 34 | 35 | addChildCommands(&cmd) 36 | 37 | return &cmd 38 | } 39 | 40 | func addChildCommands(cmd *cobra.Command) { 41 | cmd.AddCommand( 42 | auth.NewCmdAuth(), 43 | config.NewCmdConfig(), 44 | product.NewCmdProduct(), 45 | customer.NewCmdCustomer(), 46 | order.NewCmdOrder(), 47 | export.NewCmdExport(), 48 | ingest.NewCmdImport(), 49 | webhook.NewCmdWebhook(), 50 | version.NewCmdVersion(), 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /internal/cmd/webhook/unsubscribe/unsubscribe.go: -------------------------------------------------------------------------------- 1 | package unsubscribe 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/ankitpokhrel/shopctl" 7 | "github.com/ankitpokhrel/shopctl/internal/api" 8 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 9 | ) 10 | 11 | const ( 12 | helpText = `Unsubscribe lets you delete a webhook subscription.` 13 | 14 | examples = `$ shopctl webhook unsubscribe 123456789 15 | $ shopctl webhook unsubscribe gid://shopify/WebhookSubscription/123456789` 16 | ) 17 | 18 | // NewCmdUnsubscribe constructs a new webhook subscription delete command. 19 | func NewCmdUnsubscribe() *cobra.Command { 20 | return &cobra.Command{ 21 | Use: "unsubscribe WEBHOOK_ID", 22 | Short: "Delete a webhook subscription", 23 | Long: helpText, 24 | Example: examples, 25 | Args: cobra.MinimumNArgs(1), 26 | Aliases: []string{"unsub", "delete", "del"}, 27 | Annotations: map[string]string{ 28 | "help:args": `WEBHOOK_ID full or numeric webhook subscription ID, eg: 88561444456 or gid://shopify/WebhookSubscription/88561444456`, 29 | }, 30 | RunE: func(cmd *cobra.Command, args []string) error { 31 | client := cmd.Context().Value(cmdutil.KeyGQLClient).(*api.GQLClient) 32 | 33 | cmdutil.ExitOnErr(run(cmd, args, client)) 34 | return nil 35 | }, 36 | } 37 | } 38 | 39 | func run(_ *cobra.Command, args []string, client *api.GQLClient) error { 40 | subID := shopctl.ShopifyWebhookSubscriptionID(args[0]) 41 | 42 | _, err := client.DeleteWebhook(subID) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | cmdutil.Success("Webhook unsubscribed successfully") 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /internal/cmd/order/order.go: -------------------------------------------------------------------------------- 1 | package order 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/ankitpokhrel/shopctl/internal/api" 9 | "github.com/ankitpokhrel/shopctl/internal/cmd/order/list" 10 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 11 | "github.com/ankitpokhrel/shopctl/internal/config" 12 | ) 13 | 14 | const helpText = `Interact with the orders data on your store.` 15 | 16 | // NewCmdOrder builds a new order command. 17 | func NewCmdOrder() *cobra.Command { 18 | cmd := cobra.Command{ 19 | Use: "order", 20 | Short: "Interact with the orders data", 21 | Long: helpText, 22 | Annotations: map[string]string{"cmd:main": "true"}, 23 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 24 | cmdutil.ExitOnErr(preRun(cmd, args)) 25 | return nil 26 | }, 27 | RunE: func(cmd *cobra.Command, args []string) error { 28 | cmdutil.ExitOnErr(run(cmd, args)) 29 | return nil 30 | }, 31 | } 32 | 33 | cmd.AddCommand( 34 | list.NewCmdList(), 35 | ) 36 | 37 | return &cmd 38 | } 39 | 40 | func preRun(cmd *cobra.Command, _ []string) error { 41 | cfg, err := config.NewShopConfig() 42 | if err != nil { 43 | return err 44 | } 45 | 46 | ctx, err := cmdutil.GetContext(cmd, cfg) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | gqlClient := api.NewGQLClient(ctx) 52 | cmd.SetContext(context.WithValue(cmd.Context(), cmdutil.KeyContext, ctx)) 53 | cmd.SetContext(context.WithValue(cmd.Context(), cmdutil.KeyGQLClient, gqlClient)) 54 | 55 | return nil 56 | } 57 | 58 | func run(cmd *cobra.Command, _ []string) error { 59 | return cmd.Help() 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Publish a Docker image 2 | run-name: Docker image for ${{ github.ref_name }} 3 | 4 | on: 5 | workflow_dispatch: 6 | release: 7 | types: [published, prereleased] 8 | 9 | env: 10 | REGISTRY: ghcr.io 11 | IMAGE_NAME: ${{ github.repository }} 12 | 13 | jobs: 14 | docker-image: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: read 18 | packages: write 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Log in to the Container registry 25 | uses: docker/login-action@v3 26 | with: 27 | registry: ${{ env.REGISTRY }} 28 | username: ${{ github.actor }} 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: Extract metadata (tags, labels) for Docker 32 | id: meta 33 | uses: docker/metadata-action@v5 34 | with: 35 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 36 | 37 | - name: Set up QEMU 38 | uses: docker/setup-qemu-action@v3 39 | 40 | - name: Set up Docker Buildx 41 | uses: docker/setup-buildx-action@v2 42 | with: 43 | platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7,linux/arm/v8 44 | 45 | - name: Build and push Docker image 46 | uses: docker/build-push-action@v5 47 | with: 48 | context: . 49 | file: ./Dockerfile 50 | push: true 51 | tags: ${{ steps.meta.outputs.tags }} 52 | labels: ${{ steps.meta.outputs.labels }} 53 | platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7,linux/arm/v8 54 | -------------------------------------------------------------------------------- /internal/cmd/product/media/media.go: -------------------------------------------------------------------------------- 1 | package media 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/ankitpokhrel/shopctl/internal/api" 9 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/media/attach" 10 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/media/detach" 11 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 12 | "github.com/ankitpokhrel/shopctl/internal/config" 13 | ) 14 | 15 | const helpText = `Media lets you interact with product media.` 16 | 17 | // NewCmdMedia builds a new product variant command. 18 | func NewCmdMedia() *cobra.Command { 19 | cmd := cobra.Command{ 20 | Use: "media", 21 | Short: "Interact with product media", 22 | Long: helpText, 23 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 24 | cmdutil.ExitOnErr(preRun(cmd, args)) 25 | return nil 26 | }, 27 | RunE: func(cmd *cobra.Command, args []string) error { 28 | cmdutil.ExitOnErr(run(cmd, args)) 29 | return nil 30 | }, 31 | } 32 | 33 | cmd.AddCommand( 34 | attach.NewCmdAttach(), 35 | detach.NewCmdDetach(), 36 | ) 37 | return &cmd 38 | } 39 | 40 | func preRun(cmd *cobra.Command, _ []string) error { 41 | cfg, err := config.NewShopConfig() 42 | if err != nil { 43 | return err 44 | } 45 | 46 | ctx, err := cmdutil.GetContext(cmd, cfg) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | gqlClient := api.NewGQLClient(ctx) 52 | cmd.SetContext(context.WithValue(cmd.Context(), cmdutil.KeyContext, ctx)) 53 | cmd.SetContext(context.WithValue(cmd.Context(), cmdutil.KeyGQLClient, gqlClient)) 54 | 55 | return nil 56 | } 57 | 58 | func run(cmd *cobra.Command, _ []string) error { 59 | return cmd.Help() 60 | } 61 | -------------------------------------------------------------------------------- /pkg/gql/client/client_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestClient_Request(t *testing.T) { 14 | var errResponse bool 15 | 16 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | assert.Equal(t, "/", r.URL.Path) 18 | assert.Equal(t, "application/json", r.Header.Get("Content-Type")) 19 | assert.Equal(t, "12345", r.Header.Get("X-Shopify-Access-Token")) 20 | 21 | var ( 22 | resp []byte 23 | err error 24 | ) 25 | 26 | if errResponse { 27 | resp, err = os.ReadFile("./testdata/error.json") 28 | } else { 29 | resp, err = os.ReadFile("./testdata/shop.json") 30 | } 31 | assert.NoError(t, err) 32 | 33 | w.Header().Set("Content-Type", "application/json") 34 | w.WriteHeader(200) 35 | _, _ = w.Write(resp) 36 | })) 37 | defer server.Close() 38 | 39 | client := NewClient(server.URL, "12345") 40 | headers := Header{ 41 | "Content-Type": "application/json", 42 | "X-Shopify-Access-Token": "12345", 43 | } 44 | body := []byte(`{ "query": "{ shop { name }" }`) 45 | resp, err := client.Request(context.Background(), body, headers) 46 | assert.NoError(t, err) 47 | defer func() { 48 | _ = resp.Body.Close() 49 | }() 50 | assert.Equal(t, http.StatusOK, resp.StatusCode) 51 | 52 | errResponse = true 53 | 54 | body = []byte(`{ "query": "{ shop { names }" }`) 55 | resp, err = client.Request(context.Background(), body, headers) 56 | assert.NoError(t, err) 57 | defer func() { 58 | _ = resp.Body.Close() 59 | }() 60 | assert.Equal(t, http.StatusOK, resp.StatusCode) 61 | } 62 | -------------------------------------------------------------------------------- /pkg/search/doc.go: -------------------------------------------------------------------------------- 1 | // Package search provides a query builder for constructing Shopify search syntax queries. 2 | // It offers a simple and fluent API to build complex queries by chaining conditions. 3 | // See https://shopify.dev/docs/api/usage/search-syntax for Shopify search syntax details. 4 | // 5 | // Example usage: 6 | // 7 | // // Query: title:"red shirt" 8 | // q1 := search.New().Equal("title", "red shirt") 9 | // fmt.Println(q1.Build()) // Output: title:"red shirt" 10 | // 11 | // // Query: title:"red shirt" AND price:>10 12 | // q2 := search.New(). 13 | // Equal("title", "red shirt"). 14 | // And(). 15 | // GreaterThan("price", 10) 16 | // fmt.Println(q2.Build()) // Output: title:"red shirt" AND price:>10 17 | // 18 | // // Query: (product_type:t-shirt OR product_type:sweater) 19 | // q3 := search.New().In("product_type", "t-shirt", "sweater") 20 | // fmt.Println(q3.Build()) // Output: (product_type:"t-shirt" OR product_type:"sweater") 21 | // 22 | // // Query: (title:"red shirt" AND description:*cotton*) AND price:<=20 23 | // q4 := search.New(). 24 | // Group(func(sub *search.Query) { 25 | // sub.Equal("title", "red shirt"). 26 | // And(). 27 | // Contains("description", "cotton") 28 | // }). 29 | // And(). 30 | // LessThanOrEqual("price", 20) 31 | // fmt.Println(q4.Build()) // Output: ((title:"red shirt" AND description:*cotton*)) AND price:<=20 32 | // 33 | // // Query: -status:"sold out" OR status:available 34 | // q5 := search.New(). 35 | // NotEqual("status", "sold out"). 36 | // Or(). 37 | // Equal("status", "available") 38 | // fmt.Println(q5.Build()) // Output: -status:"sold out" OR status:available 39 | package search 40 | -------------------------------------------------------------------------------- /internal/cmd/product/option/option.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/ankitpokhrel/shopctl/internal/api" 9 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/option/add" 10 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/option/edit" 11 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/option/remove" 12 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 13 | "github.com/ankitpokhrel/shopctl/internal/config" 14 | ) 15 | 16 | const helpText = `Option lets you interact with product options.` 17 | 18 | // NewCmdOption builds a new product option command. 19 | func NewCmdOption() *cobra.Command { 20 | cmd := cobra.Command{ 21 | Use: "option", 22 | Short: "Interact with product options", 23 | Long: helpText, 24 | Aliases: []string{"opt"}, 25 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 26 | cmdutil.ExitOnErr(preRun(cmd, args)) 27 | return nil 28 | }, 29 | RunE: func(cmd *cobra.Command, args []string) error { 30 | cmdutil.ExitOnErr(run(cmd, args)) 31 | return nil 32 | }, 33 | } 34 | 35 | cmd.AddCommand( 36 | add.NewCmdAdd(), 37 | edit.NewCmdEdit(), 38 | remove.NewCmdRemove(), 39 | ) 40 | return &cmd 41 | } 42 | 43 | func preRun(cmd *cobra.Command, _ []string) error { 44 | cfg, err := config.NewShopConfig() 45 | if err != nil { 46 | return err 47 | } 48 | 49 | ctx, err := cmdutil.GetContext(cmd, cfg) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | gqlClient := api.NewGQLClient(ctx) 55 | cmd.SetContext(context.WithValue(cmd.Context(), cmdutil.KeyContext, ctx)) 56 | cmd.SetContext(context.WithValue(cmd.Context(), cmdutil.KeyGQLClient, gqlClient)) 57 | 58 | return nil 59 | } 60 | 61 | func run(cmd *cobra.Command, _ []string) error { 62 | return cmd.Help() 63 | } 64 | -------------------------------------------------------------------------------- /internal/engine/engine_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | // MockDoer is a mock implementation of the Doer interface. 11 | type MockDoer struct { 12 | doFunc func(Resource, any) (any, error) 13 | } 14 | 15 | // Do mocks the Do method. 16 | func (m *MockDoer) Do(r Resource, d any) (any, error) { 17 | return m.doFunc(r, d) 18 | } 19 | 20 | func TestEngine_Run(t *testing.T) { 21 | doer := &MockDoer{ 22 | doFunc: func(r Resource, d any) (any, error) { 23 | if r.Type == "fail" { 24 | return nil, errors.New("mock error") 25 | } 26 | return nil, nil 27 | }, 28 | } 29 | 30 | engine := New(doer) 31 | engine.Register(Product) 32 | 33 | done := make(chan struct{}) 34 | go func() { 35 | defer close(done) 36 | engine.Add(Product, ResourceCollection{ 37 | Parent: func() *Resource { 38 | r := Resource{Type: Product} 39 | return &r 40 | }(), 41 | Children: []Resource{ 42 | {Type: ProductOption}, 43 | {Type: ProductVariant}, 44 | {Type: "fail"}, 45 | }, 46 | }) 47 | engine.Done(Product) 48 | }() 49 | 50 | // Run the engine. 51 | results := engine.Run(Product) 52 | 53 | // Collect results. 54 | collected := make([]Result, 0) 55 | for res := range results { 56 | collected = append(collected, res) 57 | } 58 | 59 | assert.Len(t, collected, 4) 60 | assert.Equal(t, "product", string(collected[0].ResourceType)) 61 | assert.Nil(t, collected[0].Err) 62 | assert.Equal(t, "product_option", string(collected[1].ResourceType)) 63 | assert.Nil(t, collected[1].Err) 64 | assert.Equal(t, "product_variant", string(collected[2].ResourceType)) 65 | assert.Nil(t, collected[2].Err) 66 | assert.Equal(t, "fail", string(collected[3].ResourceType)) 67 | assert.NotNil(t, collected[3].Err) 68 | 69 | <-done 70 | } 71 | -------------------------------------------------------------------------------- /internal/cmd/product/variant/variant.go: -------------------------------------------------------------------------------- 1 | package variant 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/ankitpokhrel/shopctl/internal/api" 9 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/variant/add" 10 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/variant/edit" 11 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/variant/list" 12 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/variant/remove" 13 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 14 | "github.com/ankitpokhrel/shopctl/internal/config" 15 | ) 16 | 17 | const helpText = `Variant lets you interact with product variants.` 18 | 19 | // NewCmdVariant builds a new product variant command. 20 | func NewCmdVariant() *cobra.Command { 21 | cmd := cobra.Command{ 22 | Use: "variant", 23 | Short: "Interact with product variants", 24 | Long: helpText, 25 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 26 | cmdutil.ExitOnErr(preRun(cmd, args)) 27 | return nil 28 | }, 29 | RunE: func(cmd *cobra.Command, args []string) error { 30 | cmdutil.ExitOnErr(run(cmd, args)) 31 | return nil 32 | }, 33 | } 34 | 35 | cmd.AddCommand( 36 | list.NewCmdList(), 37 | add.NewCmdAdd(), 38 | edit.NewCmdEdit(), 39 | remove.NewCmdRemove(), 40 | ) 41 | return &cmd 42 | } 43 | 44 | func preRun(cmd *cobra.Command, _ []string) error { 45 | cfg, err := config.NewShopConfig() 46 | if err != nil { 47 | return err 48 | } 49 | 50 | ctx, err := cmdutil.GetContext(cmd, cfg) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | gqlClient := api.NewGQLClient(ctx) 56 | cmd.SetContext(context.WithValue(cmd.Context(), cmdutil.KeyContext, ctx)) 57 | cmd.SetContext(context.WithValue(cmd.Context(), cmdutil.KeyGQLClient, gqlClient)) 58 | 59 | return nil 60 | } 61 | 62 | func run(cmd *cobra.Command, _ []string) error { 63 | return cmd.Help() 64 | } 65 | -------------------------------------------------------------------------------- /pkg/fmtout/csv.go: -------------------------------------------------------------------------------- 1 | package fmtout 2 | 3 | import ( 4 | "encoding/csv" 5 | "io" 6 | "slices" 7 | "strings" 8 | ) 9 | 10 | // CSVFormatter constructs formatter for csv. 11 | type CSVFormatter struct { 12 | noHeaders bool 13 | columns []string 14 | contents [][]string 15 | } 16 | 17 | // CSVOption is a functional opt for CSVFormatter. 18 | type CSVOption func(*CSVFormatter) 19 | 20 | // NewCSV builds a new csv formatter. 21 | func NewCSV(cols []string, rows [][]string, opts ...CSVOption) *CSVFormatter { 22 | var ( 23 | headers []string 24 | keepCols []int 25 | ) 26 | 27 | csvfmt := CSVFormatter{noHeaders: true} 28 | for _, o := range opts { 29 | o(&csvfmt) 30 | } 31 | 32 | for i, c := range cols { 33 | if slices.Contains(csvfmt.columns, keyme(c)) { 34 | headers = append(headers, c) 35 | keepCols = append(keepCols, i) 36 | } 37 | } 38 | 39 | contents := make([][]string, len(rows)) 40 | for i, row := range rows { 41 | var newRow []string 42 | for _, j := range keepCols { 43 | if j < len(row) { 44 | newRow = append(newRow, row[j]) 45 | } 46 | } 47 | contents[i] = newRow 48 | } 49 | if !csvfmt.noHeaders { 50 | contents = append([][]string{headers}, contents...) 51 | } 52 | csvfmt.contents = contents 53 | 54 | return &csvfmt 55 | } 56 | 57 | // WithNoHeaders sets noHeaders option. 58 | func WithNoHeaders(ok bool) CSVOption { 59 | return func(t *CSVFormatter) { 60 | t.noHeaders = ok 61 | } 62 | } 63 | 64 | // WithColumns sets columns to display. 65 | func WithColumns(cols []string) CSVOption { 66 | return func(t *CSVFormatter) { 67 | t.columns = cols 68 | } 69 | } 70 | 71 | // Format formats the csv output. 72 | func (t *CSVFormatter) Format(w io.Writer) error { 73 | wrt := csv.NewWriter(w) 74 | return wrt.WriteAll(t.contents) 75 | } 76 | 77 | func keyme(s string) string { 78 | s = strings.ToLower(s) 79 | return strings.ReplaceAll(s, " ", "_") 80 | } 81 | -------------------------------------------------------------------------------- /internal/registry/file_test.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestFindFilesInDir(t *testing.T) { 12 | path := "./testdata/.tmp/" 13 | 14 | testFile := "testfile.txt" 15 | testFiles := []string{ 16 | filepath.Join(path, testFile), 17 | filepath.Join(path, "subdir1", testFile), 18 | filepath.Join(path, "subdir2", "nested", testFile), 19 | } 20 | 21 | for _, filePath := range testFiles { 22 | dir := filepath.Dir(filePath) 23 | err := os.MkdirAll(dir, 0o755) 24 | assert.NoError(t, err, "Failed to create directory %s", dir) 25 | 26 | err = os.WriteFile(filePath, []byte("test content"), 0o644) 27 | assert.NoError(t, err, "Failed to create test file %s", filePath) 28 | } 29 | 30 | locatedFiles, err := FindFilesInDir(path, testFile) 31 | assert.NoError(t, err) 32 | 33 | results := make([]string, 0) 34 | for file := range locatedFiles { 35 | assert.NoError(t, file.Err) 36 | results = append(results, file.Path) 37 | } 38 | 39 | expectedResults := make(map[string]bool) 40 | for _, path := range testFiles { 41 | expectedResults[path] = true 42 | } 43 | assert.Equal(t, len(expectedResults), len(results)) 44 | 45 | for _, result := range results { 46 | assert.True(t, expectedResults[result]) 47 | delete(expectedResults, result) 48 | } 49 | assert.Empty(t, expectedResults) 50 | 51 | // Clean up. 52 | assert.NoError(t, os.RemoveAll(path)) 53 | } 54 | 55 | func TestLookForDir(t *testing.T) { 56 | path := "./testdata/bkp/" 57 | 58 | loc, err := LookForDir("eg", path) 59 | assert.NoError(t, err) 60 | assert.Equal(t, "testdata/bkp/products/2025/01/eg", loc) 61 | } 62 | 63 | func TestLookForDirWithSuffix(t *testing.T) { 64 | path := "./testdata/bkp/" 65 | 66 | loc, err := LookForDirWithSuffix("1", path) 67 | assert.NoError(t, err) 68 | assert.Equal(t, "testdata/bkp/products/2025/01", loc) 69 | } 70 | -------------------------------------------------------------------------------- /internal/cmd/customer/customer.go: -------------------------------------------------------------------------------- 1 | package customer 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/ankitpokhrel/shopctl/internal/api" 9 | "github.com/ankitpokhrel/shopctl/internal/cmd/customer/create" 10 | "github.com/ankitpokhrel/shopctl/internal/cmd/customer/delete" 11 | "github.com/ankitpokhrel/shopctl/internal/cmd/customer/list" 12 | "github.com/ankitpokhrel/shopctl/internal/cmd/customer/update" 13 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 14 | "github.com/ankitpokhrel/shopctl/internal/config" 15 | ) 16 | 17 | const helpText = `Interact with the customers data on your store.` 18 | 19 | // NewCmdCustomer builds a new product command. 20 | func NewCmdCustomer() *cobra.Command { 21 | cmd := cobra.Command{ 22 | Use: "customer", 23 | Short: "Interact with the customer data", 24 | Long: helpText, 25 | Annotations: map[string]string{"cmd:main": "true"}, 26 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 27 | cmdutil.ExitOnErr(preRun(cmd, args)) 28 | return nil 29 | }, 30 | RunE: func(cmd *cobra.Command, args []string) error { 31 | cmdutil.ExitOnErr(run(cmd, args)) 32 | return nil 33 | }, 34 | } 35 | 36 | cmd.AddCommand( 37 | list.NewCmdList(), 38 | create.NewCmdCreate(), 39 | update.NewCmdUpdate(), 40 | delete.NewCmdDelete(), 41 | ) 42 | 43 | return &cmd 44 | } 45 | 46 | func preRun(cmd *cobra.Command, _ []string) error { 47 | cfg, err := config.NewShopConfig() 48 | if err != nil { 49 | return err 50 | } 51 | 52 | ctx, err := cmdutil.GetContext(cmd, cfg) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | gqlClient := api.NewGQLClient(ctx) 58 | cmd.SetContext(context.WithValue(cmd.Context(), cmdutil.KeyContext, ctx)) 59 | cmd.SetContext(context.WithValue(cmd.Context(), cmdutil.KeyGQLClient, gqlClient)) 60 | 61 | return nil 62 | } 63 | 64 | func run(cmd *cobra.Command, _ []string) error { 65 | return cmd.Help() 66 | } 67 | -------------------------------------------------------------------------------- /internal/cmd/webhook/webhook.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/ankitpokhrel/shopctl/internal/api" 9 | "github.com/ankitpokhrel/shopctl/internal/cmd/webhook/list" 10 | "github.com/ankitpokhrel/shopctl/internal/cmd/webhook/listen" 11 | "github.com/ankitpokhrel/shopctl/internal/cmd/webhook/subscribe" 12 | "github.com/ankitpokhrel/shopctl/internal/cmd/webhook/unsubscribe" 13 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 14 | "github.com/ankitpokhrel/shopctl/internal/config" 15 | ) 16 | 17 | // NewCmdWebhook responds to the Shopify webhooks. 18 | func NewCmdWebhook() *cobra.Command { 19 | cmd := cobra.Command{ 20 | Use: "webhook", 21 | Short: "Interact with the Shopify webhooks", 22 | Long: "Interact with the webhook topics provided by the Shopify.", 23 | Aliases: []string{"wh", "hook", "event"}, 24 | Annotations: map[string]string{"cmd:main": "true"}, 25 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 26 | cmdutil.ExitOnErr(preRun(cmd, args)) 27 | return nil 28 | }, 29 | RunE: func(cmd *cobra.Command, args []string) error { 30 | cmdutil.ExitOnErr(run(cmd, args)) 31 | return nil 32 | }, 33 | } 34 | 35 | cmd.AddCommand( 36 | list.NewCmdList(), 37 | subscribe.NewCmdSubscribe(), 38 | listen.NewCmdListen(), 39 | unsubscribe.NewCmdUnsubscribe(), 40 | ) 41 | 42 | return &cmd 43 | } 44 | 45 | func preRun(cmd *cobra.Command, _ []string) error { 46 | cfg, err := config.NewShopConfig() 47 | if err != nil { 48 | return err 49 | } 50 | 51 | ctx, err := cmdutil.GetContext(cmd, cfg) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | gqlClient := api.NewGQLClient(ctx) 57 | cmd.SetContext(context.WithValue(cmd.Context(), cmdutil.KeyContext, ctx)) 58 | cmd.SetContext(context.WithValue(cmd.Context(), cmdutil.KeyGQLClient, gqlClient)) 59 | 60 | return nil 61 | } 62 | 63 | func run(cmd *cobra.Command, _ []string) error { 64 | return cmd.Help() 65 | } 66 | -------------------------------------------------------------------------------- /internal/cmd/webhook/subscribe/subscribe.go: -------------------------------------------------------------------------------- 1 | package subscribe 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/ankitpokhrel/shopctl/internal/api" 7 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 8 | ) 9 | 10 | const ( 11 | helpText = `Subscribe lets you subscribe to a webhook event.` 12 | 13 | examples = `$ shopctl webhook subscribe --topic PRODUCTS_CREATE --url https://example.com/products/create 14 | 15 | # Subscribe webhook for customers update event 16 | $ shopctl webhook subscribe --topic CUSTOMERS_UPDATE --url https://example.com:8080/products/update` 17 | ) 18 | 19 | type flag struct { 20 | topic string 21 | url string 22 | } 23 | 24 | func (f *flag) parse(cmd *cobra.Command) { 25 | topic, err := cmd.Flags().GetString("topic") 26 | cmdutil.ExitOnErr(err) 27 | 28 | url, err := cmd.Flags().GetString("url") 29 | cmdutil.ExitOnErr(err) 30 | 31 | f.topic = topic 32 | f.url = url 33 | } 34 | 35 | // NewCmdSubscribe constructs a new webhook subscription command. 36 | func NewCmdSubscribe() *cobra.Command { 37 | cmd := cobra.Command{ 38 | Use: "subscribe", 39 | Short: "Subscribe to a webhook event", 40 | Long: helpText, 41 | Example: examples, 42 | Aliases: []string{"sub", "create"}, 43 | RunE: func(cmd *cobra.Command, args []string) error { 44 | client := cmd.Context().Value(cmdutil.KeyGQLClient).(*api.GQLClient) 45 | 46 | cmdutil.ExitOnErr(run(cmd, args, client)) 47 | return nil 48 | }, 49 | } 50 | 51 | cmd.Flags().StringP("topic", "t", "", "Webhook topic to listen to") 52 | cmd.Flags().String("url", "", "Endpoint for the webhook registration") 53 | 54 | cmd.Flags().SortFlags = false 55 | 56 | return &cmd 57 | } 58 | 59 | func run(cmd *cobra.Command, _ []string, client *api.GQLClient) error { 60 | flag := &flag{} 61 | flag.parse(cmd) 62 | 63 | res, err := client.SubscribeWebhook(flag.topic, flag.url) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | cmdutil.Success("Webhook subscribed successfully: %s", res.ID) 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /internal/cmd/config/get-contexts/get_contexts.go: -------------------------------------------------------------------------------- 1 | package getcontexts 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "text/tabwriter" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 11 | "github.com/ankitpokhrel/shopctl/internal/config" 12 | ) 13 | 14 | const ( 15 | helpText = `Display one or many contexts defined in the shopconfig file.` 16 | tabWidth = 8 17 | ) 18 | 19 | // NewCmdGetContexts is a cmd to get all available contexts. 20 | func NewCmdGetContexts() *cobra.Command { 21 | return &cobra.Command{ 22 | Use: "get-contexts", 23 | Short: "Display one or many contexts defined in the shopconfig file", 24 | Long: helpText, 25 | Aliases: []string{"get-context"}, 26 | RunE: func(cmd *cobra.Command, args []string) error { 27 | cmdutil.ExitOnErr(run(cmd, args)) 28 | return nil 29 | }, 30 | } 31 | } 32 | 33 | func run(_ *cobra.Command, args []string) error { 34 | cfg, err := config.NewShopConfig() 35 | if err != nil { 36 | return err 37 | } 38 | 39 | var out []config.StoreContext 40 | 41 | allContexts := cfg.Contexts() 42 | if len(allContexts) == 0 { 43 | return fmt.Errorf("no contexts found") 44 | } 45 | currentCtx := cfg.CurrentContext() 46 | 47 | givenCtx := "" 48 | if len(args) > 0 { 49 | givenCtx = args[0] 50 | } 51 | 52 | if givenCtx == "" { 53 | out = allContexts 54 | } else { 55 | for _, x := range allContexts { 56 | if x.Alias == givenCtx { 57 | out = append(out, x) 58 | break 59 | } 60 | } 61 | } 62 | 63 | if len(out) == 0 { 64 | return fmt.Errorf("context not found: %q", givenCtx) 65 | } 66 | 67 | b := new(bytes.Buffer) 68 | w := tabwriter.NewWriter(b, 0, tabWidth, 1, '\t', 0) 69 | 70 | _, _ = fmt.Fprintf(w, "%s\t %s\n", "NAME", "STORE") 71 | for _, x := range out { 72 | name := x.Alias 73 | if name == currentCtx { 74 | name += "*" 75 | } 76 | _, _ = fmt.Fprintf(w, "%s\t %s\n", name, x.Store) 77 | } 78 | 79 | if err := w.Flush(); err != nil { 80 | return err 81 | } 82 | 83 | fmt.Println(b.String()) 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /internal/oauth/templates/success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Authentication Confirmation 8 | 53 | 54 | 55 | 56 |
57 |

Authentication Complete

58 |

59 | You are successfully authenticated to ShopCTL. 60 |
61 | You may now close this window and return to your CLI app. 62 |

63 | Close this window 64 |
65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /pkg/search/search_test.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestQueryBuild(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | build func() *Query 13 | expected string 14 | }{ 15 | { 16 | name: "simple query", 17 | build: func() *Query { 18 | return New().Eq("title", "red shirt") 19 | }, 20 | expected: `title:"red shirt"`, 21 | }, 22 | { 23 | name: "AND and greater than condition", 24 | build: func() *Query { 25 | return New(). 26 | Eq("title", "red shirt"). 27 | And(). 28 | Gt("price", 10) 29 | }, 30 | expected: `title:"red shirt" AND price:>10`, 31 | }, 32 | { 33 | name: "in clause", 34 | build: func() *Query { 35 | return New().In("product_type", "t-shirt", "sweater", "sport shoes") 36 | }, 37 | expected: `(product_type:t-shirt OR product_type:sweater OR product_type:"sport shoes")`, 38 | }, 39 | { 40 | name: "complex query with grouping", 41 | build: func() *Query { 42 | return New(). 43 | Group(func(sub *Query) { 44 | sub.Eq("title", "red shirt"). 45 | And(). 46 | Contains("description", "cotton") 47 | }). 48 | And(). 49 | Lte("price", 20) 50 | }, 51 | // The outer group wraps the subquery. 52 | expected: `(title:"red shirt" AND description:*cotton*) AND price:<=20`, 53 | }, 54 | { 55 | name: "not equal with OR", 56 | build: func() *Query { 57 | return New(). 58 | Neq("status", "sold out"). 59 | Or(). 60 | Eq("status", "available") 61 | }, 62 | expected: `-status:"sold out" OR status:available`, 63 | }, 64 | { 65 | name: "query with GreaterThanOrEqual and LessThan condition", 66 | build: func() *Query { 67 | return New(). 68 | Gte("rating", 4). 69 | And(). 70 | Lt("rating", 5) 71 | }, 72 | expected: `rating:>=4 AND rating:<5`, 73 | }, 74 | } 75 | 76 | for _, tc := range tests { 77 | t.Run(tc.name, func(t *testing.T) { 78 | q := tc.build() 79 | 80 | assert.Equal(t, tc.expected, q.Build()) 81 | }) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /internal/api/errors.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/ankitpokhrel/shopctl/schema" 7 | ) 8 | 9 | // Error represents an error response from the Shopify API. 10 | type Error struct { 11 | Message string `json:"message"` 12 | Locations []struct { 13 | Line int `json:"line"` 14 | Column int `json:"column"` 15 | } `json:"locations"` 16 | Extensions struct { 17 | Value any `json:"value"` 18 | } `json:"extensions"` 19 | } 20 | 21 | // Error implements the error interface. 22 | func (e *Error) Error() string { 23 | return clean(e.Message) 24 | } 25 | 26 | // Errors is a list of errors. 27 | type Errors []Error 28 | 29 | // Error implements the error interface. 30 | func (e Errors) Error() string { 31 | errs := make([]string, 0, len(e)) 32 | for _, err := range e { 33 | errs = append(errs, err.Error()) 34 | } 35 | return strings.Join(errs, ", ") 36 | } 37 | 38 | // UserErrors is a list of user errors. 39 | type UserErrors []schema.UserError 40 | 41 | // Err implements the error interface. 42 | func (e UserErrors) Error() string { 43 | errs := make([]string, 0, len(e)) 44 | for _, err := range e { 45 | errs = append(errs, clean(err.Message)) 46 | } 47 | return strings.Join(errs, ", ") 48 | } 49 | 50 | // Extensions is the extensions returned by the Shopify API. 51 | type Extensions struct { 52 | Cost QueryCost `json:"cost"` 53 | } 54 | 55 | // QueryCost is the cost of the query returned by the Shopify API. 56 | type QueryCost struct { 57 | RequestedQueryCost float64 `json:"requestedQueryCost"` 58 | ActualQueryCost float64 `json:"actualQueryCost"` 59 | ThrottleStatus ThrottleStatus `json:"throttleStatus"` 60 | } 61 | 62 | // ThrottleStatus is the status of the throttle returned by the Shopify API. 63 | type ThrottleStatus struct { 64 | MaximumAvailable float64 `json:"maximumAvailable"` 65 | CurrentlyAvailable float64 `json:"currentlyAvailable"` 66 | RestoreRate float64 `json:"restoreRate"` 67 | } 68 | 69 | func clean(input string) string { 70 | input = strings.ReplaceAll(input, "\n", " ") 71 | input = strings.ReplaceAll(input, "\r", " ") 72 | return input 73 | } 74 | -------------------------------------------------------------------------------- /internal/api/client.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/zalando/go-keyring" 9 | 10 | "github.com/ankitpokhrel/shopctl" 11 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 12 | "github.com/ankitpokhrel/shopctl/internal/config" 13 | "github.com/ankitpokhrel/shopctl/pkg/gql/client" 14 | "github.com/ankitpokhrel/shopctl/pkg/tlog" 15 | ) 16 | 17 | // GQLClient is a GraphQL client. 18 | type GQLClient struct { 19 | *client.Client 20 | logger *tlog.Logger 21 | } 22 | 23 | // GQLClientFunc is a functional opt for GQLClient. 24 | type GQLClientFunc func(*GQLClient) 25 | 26 | // NewGQLClient constructs new GraphQL client for a store. 27 | func NewGQLClient(ctx *config.StoreContext, opts ...GQLClientFunc) *GQLClient { 28 | var ( 29 | token string 30 | err error 31 | defined bool 32 | 33 | store = ctx.Store 34 | server = fmt.Sprintf("https://%s/admin/api/%s/graphql.json", store, shopctl.ShopifyApiVersion) 35 | service = fmt.Sprintf("shopctl:%s", cmdutil.GetStoreSlug(store)) 36 | ) 37 | 38 | // The `SHOPIFY_ACCESS_TOKEN_{CURRENT_CONTEXt}` env has the highest priority when looking for a token. 39 | // Second, we check for `SHOPIFY_ACCESS_TOKEN` env. We will then look into other secure storages like 40 | // system's keyring/keychain. Finally, we'll fallback to read from insecure storage like config files. 41 | if token, defined = os.LookupEnv(fmt.Sprintf("SHOPIFY_ACCESS_TOKEN_%s", strings.ToUpper(ctx.Alias))); !defined { 42 | if token, defined = os.LookupEnv("SHOPIFY_ACCESS_TOKEN"); !defined { 43 | token, err = keyring.Get(service, store) 44 | if err != nil || token == "" { 45 | token = config.GetToken(store) 46 | } 47 | } 48 | } 49 | 50 | c := GQLClient{} 51 | for _, opt := range opts { 52 | opt(&c) 53 | } 54 | if c.logger == nil { 55 | c.logger = tlog.New(tlog.VerboseLevel(tlog.VL1), false) 56 | } 57 | c.Client = client.NewClient(server, token, client.WithLogger(c.logger)) 58 | 59 | return &c 60 | } 61 | 62 | // LogRequest sets custom logger for the client. 63 | func LogRequest(lgr *tlog.Logger) GQLClientFunc { 64 | return func(c *GQLClient) { 65 | c.logger = lgr 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ############## 2 | # Build vars # 3 | ############## 4 | 5 | # The 'GIT_COMMIT' variable is used to retrieve the commit hash of the current commit (HEAD). 6 | # 7 | # If there are any uncommitted changes, a stash entry will be created with `git stash create`, 8 | # and the commit hash of the latest commit (HEAD) will be used instead. 9 | GIT_COMMIT ?= $(shell { git stash create; git rev-parse HEAD; } | head -n 1) 10 | 11 | # https://reproducible-builds.org/docs/source-date-epoch/ 12 | # 13 | # The 'SOURCE_DATE_EPOCH' variable is used to set the timestamp of the commit referenced by 'GIT_COMMIT'. 14 | # It ensures that builds are reproducible by using a consistent, fixed timestamp for the source code. 15 | export SOURCE_DATE_EPOCH ?= $(shell git show -s --format="%ct" $(GIT_COMMIT)) 16 | 17 | VERSION ?= $(shell git symbolic-ref -q --short HEAD || git describe --tags --exact-match) 18 | VERSION_PKG = github.com/ankitpokhrel/shopctl/internal/version 19 | export LDFLAGS += -X $(VERSION_PKG).GitCommit=$(GIT_COMMIT) 20 | export LDFLAGS += -X $(VERSION_PKG).SourceDateEpoch=$(SOURCE_DATE_EPOCH) 21 | export LDFLAGS += -X $(VERSION_PKG).Version=$(VERSION) 22 | export LDFLAGS += -s 23 | export LDFLAGS += -w 24 | 25 | export CGO_ENABLED ?= 0 26 | export GOCACHE ?= $(CURDIR)/.gocache 27 | 28 | .PHONY: all 29 | all: build 30 | 31 | .PHONY: deps 32 | deps: 33 | go mod vendor -v 34 | 35 | .PHONY: build 36 | build: deps 37 | go build -ldflags='$(LDFLAGS)' ./... 38 | 39 | .PHONY: install 40 | install: 41 | CGO_ENABLED=1 go install -ldflags='$(LDFLAGS)' ./cmd/... 42 | 43 | .PHONY: lint 44 | lint: 45 | @if ! command -v golangci-lint > /dev/null 2>&1; then \ 46 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | \ 47 | sh -s -- -b "$$(go env GOPATH)/bin" v2.1.2 ; \ 48 | fi 49 | golangci-lint run ./... 50 | 51 | .PHONY: test 52 | test: 53 | @go clean -testcache 54 | CGO_ENABLED=1 go test -race ./... 55 | 56 | .PHONY: coverage 57 | coverage: 58 | go test -cover ./... 59 | 60 | .PHONY: dev 61 | dev: 62 | @docker build -t shopctl:latest . 63 | 64 | .PHONY: exec 65 | exec: 66 | @docker exec -it shopctl sh 67 | 68 | .PHONY: ci 69 | ci: lint test 70 | 71 | .PHONY: clean 72 | clean: 73 | go clean -x ./... 74 | 75 | .PHONY: distclean 76 | distclean: 77 | go clean -x -cache -testcache -modcache 78 | -------------------------------------------------------------------------------- /internal/cmd/product/product.go: -------------------------------------------------------------------------------- 1 | package product 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/ankitpokhrel/shopctl/internal/api" 9 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/clone" 10 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/create" 11 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/delete" 12 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/list" 13 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/media" 14 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/option" 15 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/peek" 16 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/update" 17 | "github.com/ankitpokhrel/shopctl/internal/cmd/product/variant" 18 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 19 | "github.com/ankitpokhrel/shopctl/internal/config" 20 | ) 21 | 22 | const helpText = `Interact with the products data on your store.` 23 | 24 | // NewCmdProduct builds a new product command. 25 | func NewCmdProduct() *cobra.Command { 26 | cmd := cobra.Command{ 27 | Use: "product", 28 | Short: "Interact with the products data", 29 | Long: helpText, 30 | Annotations: map[string]string{"cmd:main": "true"}, 31 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 32 | cmdutil.ExitOnErr(preRun(cmd, args)) 33 | return nil 34 | }, 35 | RunE: func(cmd *cobra.Command, args []string) error { 36 | cmdutil.ExitOnErr(run(cmd, args)) 37 | return nil 38 | }, 39 | } 40 | 41 | cmd.AddCommand( 42 | list.NewCmdList(), 43 | peek.NewCmdPeek(), 44 | create.NewCmdCreate(), 45 | update.NewCmdUpdate(), 46 | delete.NewCmdDelete(), 47 | option.NewCmdOption(), 48 | variant.NewCmdVariant(), 49 | media.NewCmdMedia(), 50 | clone.NewCmdClone(), 51 | ) 52 | 53 | return &cmd 54 | } 55 | 56 | func preRun(cmd *cobra.Command, _ []string) error { 57 | cfg, err := config.NewShopConfig() 58 | if err != nil { 59 | return err 60 | } 61 | 62 | ctx, err := cmdutil.GetContext(cmd, cfg) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | gqlClient := api.NewGQLClient(ctx) 68 | cmd.SetContext(context.WithValue(cmd.Context(), cmdutil.KeyContext, ctx)) 69 | cmd.SetContext(context.WithValue(cmd.Context(), cmdutil.KeyGQLClient, gqlClient)) 70 | 71 | return nil 72 | } 73 | 74 | func run(cmd *cobra.Command, _ []string) error { 75 | return cmd.Help() 76 | } 77 | -------------------------------------------------------------------------------- /internal/cmd/auth/login/login.go: -------------------------------------------------------------------------------- 1 | package login 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/zalando/go-keyring" 10 | 11 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 12 | "github.com/ankitpokhrel/shopctl/internal/config" 13 | "github.com/ankitpokhrel/shopctl/internal/oauth" 14 | ) 15 | 16 | const ( 17 | helpText = "Initiate oAuth login to the Shopify account." 18 | examples = `$ shopctl auth login` 19 | ) 20 | 21 | // NewCmdLogin is a login command. 22 | func NewCmdLogin() *cobra.Command { 23 | return &cobra.Command{ 24 | Use: "login", 25 | Short: "Login to a Shopify account", 26 | Long: helpText, 27 | Example: examples, 28 | Annotations: map[string]string{"cmd:main": "true"}, 29 | RunE: login, 30 | } 31 | } 32 | 33 | func login(cmd *cobra.Command, _ []string) error { 34 | store, err := cmd.Flags().GetString("store") 35 | if err != nil { 36 | return fmt.Errorf("please pass in the store you want to operate on") 37 | } 38 | 39 | host, alias, err := getHostAndAlias(store) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | authFlow := oauth.NewFlow(host) 45 | if err := authFlow.Initiate(); err != nil { 46 | fmt.Printf("\n! Failed to authenticate with Shopify: %s\n", err) 47 | return err 48 | } 49 | 50 | shopCfg, err := config.NewShopConfig() 51 | if err != nil { 52 | return err 53 | } 54 | storeCtx := config.StoreContext{ 55 | Alias: alias, 56 | Store: host, 57 | } 58 | service := fmt.Sprintf("shopctl:%s", cmdutil.GetStoreSlug(store)) 59 | 60 | if err := keyring.Set(service, store, authFlow.Token.AccessToken); err != nil { 61 | fmt.Printf("\n! Failed to save token to a secure storage") 62 | fmt.Printf("\n! Using insecure plain text storage\n") 63 | 64 | storeCtx.Token = &authFlow.Token.AccessToken 65 | } 66 | 67 | shopCfg.SetStoreContext(&storeCtx) 68 | if err := shopCfg.Save(); err != nil { 69 | return err 70 | } 71 | 72 | fmt.Printf("\n! Successfully authenticated with Shopify\n") 73 | return nil 74 | } 75 | 76 | func getHostAndAlias(store string) (string, string, error) { 77 | if !strings.HasPrefix(store, "http://") && !strings.HasPrefix(store, "https://") { 78 | store = "https://" + store 79 | } 80 | 81 | myshopifyURL, err := url.Parse(store) 82 | if err != nil { 83 | return "", "", err 84 | } 85 | 86 | host := myshopifyURL.Hostname() 87 | suffix := ".myshopify.com" 88 | 89 | if !strings.HasSuffix(host, suffix) { 90 | return "", "", fmt.Errorf("URL is not a valid myshopify domain") 91 | } 92 | return host, strings.TrimSuffix(host, suffix), nil 93 | } 94 | -------------------------------------------------------------------------------- /internal/engine/engine.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | ) 7 | 8 | const ( 9 | // TODO: Decide number of workers. 10 | numWorkers = 3 11 | ) 12 | 13 | // ErrSkipChildren is an event that signals the engine to skip processing children. 14 | var ErrSkipChildren = fmt.Errorf("skip processing children") 15 | 16 | // Result is a result of the engine operation. 17 | type Result struct { 18 | ParentResourceType ResourceType 19 | ResourceType ResourceType 20 | Err error 21 | } 22 | 23 | // Doer is an interface that defines the handler of the engine. 24 | type Doer interface { 25 | Do(Resource, any) (any, error) 26 | } 27 | 28 | // Engine is an execution engine. 29 | type Engine struct { 30 | mux sync.Mutex 31 | doer Doer 32 | jobs map[ResourceType]chan ResourceCollection 33 | } 34 | 35 | // New creates a new engine. 36 | func New(d Doer) *Engine { 37 | return &Engine{ 38 | mux: sync.Mutex{}, 39 | doer: d, 40 | jobs: make(map[ResourceType]chan ResourceCollection, 0), 41 | } 42 | } 43 | 44 | // Doer returns the doer. 45 | func (e *Engine) Doer() Doer { 46 | return e.doer 47 | } 48 | 49 | // Register registers a resource type to execute. 50 | func (e *Engine) Register(rt ResourceType) { 51 | e.mux.Lock() 52 | defer e.mux.Unlock() 53 | 54 | e.jobs[rt] = make(chan ResourceCollection) // TODO: Decide buffer size based on store size. 55 | } 56 | 57 | // Add adds a resource to the engine. 58 | func (e *Engine) Add(rt ResourceType, rc ResourceCollection) { 59 | e.mux.Lock() 60 | defer e.mux.Unlock() 61 | 62 | e.jobs[rt] <- rc 63 | } 64 | 65 | // Run starts the engine. 66 | func (e *Engine) Run(rt ResourceType) chan Result { 67 | var wg sync.WaitGroup 68 | 69 | run := func(rc ResourceCollection, out chan<- Result) { 70 | data, err := e.doer.Do(*rc.Parent, nil) 71 | out <- Result{ResourceType: rc.Parent.Type, Err: err} 72 | if err == nil { 73 | for _, r := range rc.Children { 74 | _, err := e.doer.Do(r, data) 75 | out <- Result{ParentResourceType: rc.Parent.Type, ResourceType: r.Type, Err: err} 76 | } 77 | } 78 | } 79 | 80 | out := make(chan Result, numWorkers) 81 | for range numWorkers { 82 | wg.Add(1) 83 | 84 | go func() { 85 | defer wg.Done() 86 | 87 | for rc := range e.jobs[rt] { 88 | run(rc, out) 89 | } 90 | }() 91 | } 92 | 93 | go func() { 94 | wg.Wait() 95 | close(out) 96 | }() 97 | 98 | return out 99 | } 100 | 101 | // Done marks the job for a resource type as done 102 | // by closing its receiving channel. 103 | func (e *Engine) Done(rt ResourceType) { 104 | close(e.jobs[rt]) 105 | } 106 | -------------------------------------------------------------------------------- /internal/cmd/product/variant/remove/remove.go: -------------------------------------------------------------------------------- 1 | package remove 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/ankitpokhrel/shopctl" 9 | "github.com/ankitpokhrel/shopctl/internal/api" 10 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 11 | ) 12 | 13 | const ( 14 | helpText = `Remove product variant by its id or title.` 15 | 16 | examples = `$ shopctl product variant remove 8856145494 "Red / XS" 17 | 18 | # Accepts multiple variant IDs and/or title 19 | $ shopctl product variant remove 8856145494 471883718 "Black / XL"` 20 | ) 21 | 22 | // NewCmdRemove constructs a new product option remove command. 23 | func NewCmdRemove() *cobra.Command { 24 | return &cobra.Command{ 25 | Use: "remove PRODUCT_ID", 26 | Short: "Remove product variants", 27 | Long: helpText, 28 | Example: examples, 29 | Args: cobra.MinimumNArgs(2), 30 | Aliases: []string{"delete", "del", "rm"}, 31 | RunE: func(cmd *cobra.Command, args []string) error { 32 | client := cmd.Context().Value(cmdutil.KeyGQLClient).(*api.GQLClient) 33 | 34 | cmdutil.ExitOnErr(run(cmd, args, client)) 35 | return nil 36 | }, 37 | } 38 | } 39 | 40 | func run(_ *cobra.Command, args []string, client *api.GQLClient) error { 41 | productID := shopctl.ShopifyProductID(args[0]) 42 | variantIDs := args[1:] 43 | 44 | variantsToDelete := make([]string, 0, len(variantIDs)) 45 | variantsSkipped := make([]string, 0) 46 | for _, id := range variantIDs { 47 | vid := shopctl.ShopifyProductVariantID(id) 48 | if vid == "" { 49 | parts := strings.Split(id, "/") 50 | for i := range parts { 51 | parts[i] = strings.TrimSpace(parts[i]) 52 | } 53 | title := strings.Join(parts, " / ") 54 | 55 | variant, err := client.GetProductVariantByTitle(productID, title, false) 56 | if err != nil { 57 | variantsSkipped = append(variantsSkipped, id) 58 | } else { 59 | variantsToDelete = append(variantsToDelete, variant.ID) 60 | } 61 | } else { 62 | variant, err := client.CheckProductVariantByID(vid) 63 | if err != nil { 64 | variantsSkipped = append(variantsSkipped, vid) 65 | } else { 66 | variantsToDelete = append(variantsToDelete, variant.ID) 67 | } 68 | } 69 | } 70 | 71 | if len(variantsSkipped) > 0 { 72 | cmdutil.Warn("Some variants were skipped: %s", strings.Join(variantsSkipped, ", ")) 73 | } 74 | if len(variantsToDelete) == 0 { 75 | cmdutil.Warn("Nothing to delete") 76 | return nil 77 | } 78 | 79 | res, err := client.DeleteProductVariants(productID, variantsToDelete) 80 | if err != nil { 81 | return err 82 | } 83 | cmdutil.Success( 84 | "Variants %q deleted successfully for product: %s", strings.Join(variantsToDelete, ", "), res.Product.ID, 85 | ) 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /internal/cmd/config/delete-context/delete_context.go: -------------------------------------------------------------------------------- 1 | package deletecontext 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 12 | "github.com/ankitpokhrel/shopctl/internal/config" 13 | ) 14 | 15 | const ( 16 | helpText = `Delete the specified context from the shopconfig file.` 17 | example = `# Delete a context called 'mystore' 18 | $ shopctl delete-context mystore` 19 | ) 20 | 21 | type flag struct { 22 | name string 23 | force bool 24 | } 25 | 26 | func (f *flag) parse(cmd *cobra.Command, args []string) { 27 | name := args[0] 28 | 29 | force, err := cmd.Flags().GetBool("force") 30 | cmdutil.ExitOnErr(err) 31 | 32 | f.name = name 33 | f.force = force 34 | } 35 | 36 | // NewCmdDeleteContext cmd allows you to delete a context. 37 | func NewCmdDeleteContext() *cobra.Command { 38 | cmd := cobra.Command{ 39 | Use: "delete-context CONTEXT_NAME", 40 | Short: "Delete the specified context from the shopconfig file", 41 | Long: helpText, 42 | Example: example, 43 | Args: cobra.MinimumNArgs(1), 44 | RunE: func(cmd *cobra.Command, args []string) error { 45 | cmdutil.ExitOnErr(run(cmd, args)) 46 | return nil 47 | }, 48 | } 49 | 50 | cmd.Flags().Bool("force", false, "Delete without confirmation") 51 | 52 | return &cmd 53 | } 54 | 55 | func run(cmd *cobra.Command, args []string) error { 56 | flag := &flag{} 57 | flag.parse(cmd, args) 58 | 59 | ctx := flag.name 60 | 61 | shopCfg, err := config.NewShopConfig() 62 | if err != nil { 63 | return err 64 | } 65 | if !shopCfg.HasContext(ctx) { 66 | return fmt.Errorf("no context exists with the name: %q", ctx) 67 | } 68 | 69 | if flag.force { 70 | return del(shopCfg, ctx) 71 | } 72 | 73 | fmt.Printf("You are about to delete context %q. This action is irreversible. Are you sure? (y/N): ", ctx) 74 | 75 | reader := bufio.NewReader(os.Stdin) 76 | input, _ := reader.ReadString('\n') 77 | input = strings.TrimSpace(strings.ToLower(input)) 78 | 79 | if input == "y" || input == "yes" { 80 | return del(shopCfg, ctx) 81 | } 82 | 83 | cmdutil.ExitOnErr(config.ErrActionAborted) 84 | return nil 85 | } 86 | 87 | func del(shopCfg *config.ShopConfig, ctx string) error { 88 | if shopCfg.CurrentContext() == ctx { 89 | cmdutil.Warn("WARN: This removed your active context and strategies, use \"shopctl config use-context\" to select a different one") 90 | shopCfg.UnsetCurrentContext() 91 | } 92 | 93 | shopCfg.UnsetContext(ctx) 94 | if err := shopCfg.Save(); err != nil { 95 | return err 96 | } 97 | 98 | cmdutil.Success("Deleted context %q from %s", ctx, shopCfg.Path()) 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /internal/cmd/product/option/remove/remove.go: -------------------------------------------------------------------------------- 1 | package remove 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/ankitpokhrel/shopctl" 10 | "github.com/ankitpokhrel/shopctl/internal/api" 11 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 12 | "github.com/ankitpokhrel/shopctl/schema" 13 | ) 14 | 15 | const ( 16 | helpText = `Remove product options.` 17 | 18 | examples = `$ shopct product option remove 8856145494 -nSize -nTitle -nStyle` 19 | ) 20 | 21 | // Flag wraps available command flags. 22 | type flag struct { 23 | id string 24 | options []string 25 | } 26 | 27 | func (f *flag) parse(cmd *cobra.Command, args []string) { 28 | options, err := cmd.Flags().GetStringArray("name") 29 | cmdutil.ExitOnErr(err) 30 | 31 | if len(options) == 0 { 32 | cmdutil.ExitOnErr(cmdutil.HelpErrorf("Name of options to delete is required", examples)) 33 | } 34 | 35 | f.id = shopctl.ShopifyProductID(args[0]) 36 | f.options = options 37 | } 38 | 39 | // NewCmdRemove constructs a new product option remove command. 40 | func NewCmdRemove() *cobra.Command { 41 | cmd := cobra.Command{ 42 | Use: "remove PRODUCT_ID", 43 | Short: "Remove product options", 44 | Long: helpText, 45 | Example: examples, 46 | Args: cobra.MinimumNArgs(1), 47 | Aliases: []string{"delete", "del", "rm"}, 48 | RunE: func(cmd *cobra.Command, args []string) error { 49 | client := cmd.Context().Value(cmdutil.KeyGQLClient).(*api.GQLClient) 50 | 51 | cmdutil.ExitOnErr(run(cmd, args, client)) 52 | return nil 53 | }, 54 | } 55 | cmd.Flags().StringArrayP("name", "n", []string{}, "Name of options to remove") 56 | 57 | return &cmd 58 | } 59 | 60 | func run(cmd *cobra.Command, args []string, client *api.GQLClient) error { 61 | flag := &flag{} 62 | flag.parse(cmd, args) 63 | 64 | productOptions, err := client.GetProductOptions(flag.id) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | productOptionsMap := make(map[string]*schema.ProductOption, 0) 70 | for _, o := range productOptions.Data.Product.Options { 71 | productOptionsMap[strings.ToLower(o.Name)] = &o 72 | } 73 | 74 | optionsToDelete := make([]string, 0) 75 | optionsProcessed := make([]string, 0) 76 | for _, n := range flag.options { 77 | if o, ok := productOptionsMap[strings.ToLower(n)]; ok { 78 | optionsToDelete = append(optionsToDelete, o.ID) 79 | optionsProcessed = append(optionsProcessed, o.Name) 80 | } 81 | } 82 | 83 | if len(optionsToDelete) == 0 { 84 | cmdutil.Warn("Nothing to delete") 85 | os.Exit(0) 86 | } 87 | 88 | res, err := client.DeleteProductOptions(flag.id, optionsToDelete) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | cmdutil.Success( 94 | "Options %q deleted successfully for product: %s", strings.Join(optionsProcessed, ", "), res.Product.ID, 95 | ) 96 | return nil 97 | } 98 | -------------------------------------------------------------------------------- /internal/oauth/oauth_test.go: -------------------------------------------------------------------------------- 1 | package oauth 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "net/http/httptest" 8 | "net/url" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestVerifySignature(t *testing.T) { 15 | // Secret used for signing 16 | secret := "my-secret-key" 17 | 18 | // Simulated query parameters 19 | params := url.Values{ 20 | "code": []string{"ABC3EZB97"}, 21 | "shop": []string{"example-shop.myshopify.com"}, 22 | "timestamp": []string{"1234567890"}, 23 | } 24 | 25 | // Compute the expected HMAC signature 26 | h := hmac.New(sha256.New, []byte(secret)) 27 | h.Write([]byte(params.Encode())) 28 | expectedHMAC := hex.EncodeToString(h.Sum(nil)) 29 | 30 | // Add the HMAC to the query parameters to simulate a Shopify request 31 | params.Set("hmac", expectedHMAC) 32 | 33 | // Create a fake HTTP request with the query parameters 34 | req := httptest.NewRequest("GET", "/?"+params.Encode(), nil) 35 | 36 | assert.True(t, verifySignature(req, secret)) 37 | 38 | // Test an invalid case: modify the HMAC in the request 39 | params.Set("hmac", "invalid-hmac") 40 | req = httptest.NewRequest("GET", "/?"+params.Encode(), nil) 41 | 42 | // Call verifySignature 43 | assert.False(t, verifySignature(req, secret)) 44 | } 45 | 46 | func TestGenerateState(t *testing.T) { 47 | state, err := generateState(3) 48 | assert.NoError(t, err) 49 | assert.Len(t, state, 6) 50 | 51 | state, err = generateState(8) 52 | assert.NoError(t, err) 53 | assert.Len(t, state, 16) 54 | 55 | state, err = generateState(16) 56 | assert.NoError(t, err) 57 | assert.Len(t, state, 32) 58 | } 59 | 60 | func TestValidateShopURL(t *testing.T) { 61 | cases := []struct { 62 | name string 63 | url string 64 | expected bool 65 | }{ 66 | { 67 | name: "valid shop URL", 68 | url: "https://example-shop.myshopify.com", 69 | expected: true, 70 | }, 71 | { 72 | name: "valid shop URL with trailing slash", 73 | url: "https://example-shop.myshopify.com/", 74 | expected: true, 75 | }, 76 | { 77 | name: "invalid shop URL", 78 | url: "https://invalid-shop_.myshopify.com", 79 | expected: false, 80 | }, 81 | { 82 | name: "invalid shop URL with trailing slash", 83 | url: "https://invalid-shop_.myshopify.com/", 84 | expected: false, 85 | }, 86 | { 87 | name: "valid URL but with invalid scheme", 88 | url: "ftp://example.myshopify.com/", 89 | expected: false, 90 | }, 91 | { 92 | name: "invalid URL", 93 | url: "https://shop.example.com", 94 | expected: false, 95 | }, 96 | } 97 | 98 | for _, tc := range cases { 99 | t.Run(tc.name, func(t *testing.T) { 100 | assert.Equal(t, tc.expected, validateShopURL(tc.url)) 101 | }) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /examples/scripts/weekly_customer_discounts.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################### 4 | # Script: weekly_customer_discounts.sh 5 | # 6 | # Scenario: 7 | # This script identifies high-value customers from the past week and calculates 8 | # personalized discount offers based on their total spend. The goal is to reward 9 | # engaged customers and encourage repeat purchases. 10 | # 11 | # 1. Retrieves up to 20 customers who: 12 | # - Spent more than $100 in total 13 | # - Accepted marketing communications 14 | # - Were updated within the last 24 hours 15 | # 2. For each customer: 16 | # - Proposes a 20% discount (or 30% if total spent ≥ $200) 17 | # - Calculates a rounded discount amount based on the spend 18 | # - Outputs the data to a CSV file including name, email, spend, and discount 19 | # 3. If any eligible customers are found, prints the CSV summary to stdout. 20 | # 21 | # Ideal for weekly CRM workflows or automated marketing campaigns. 22 | ############################################################################### 23 | 24 | set -euo pipefail 25 | export TZ="Europe/Berlin" 26 | 27 | DAY_START=$(date -d '1 days ago' +%Y-%m-%d) 28 | 29 | ############################################################################### 30 | # 1. Get top 20 customers that spent more than $100 in the last 24 hours and 31 | # has accepted marketing emails. 32 | ############################################################################### 33 | echo "🔍 Scanning customers updated since $DAY_START ..." 34 | customers=$(shopctl customer list --total-spent ">=100" --updated=">=$DAY_START" \ 35 | --accepts-marketing --columns id,first_name,last_name,email,amount_spent \ 36 | --csv --no-headers --with-sensitive-data --limit 20) 37 | 38 | if [[ -z "$customers" ]]; then 39 | echo "🟢 No customers spent more than \$100 since $DAY_START — nothing to do" 40 | exit 0 41 | fi 42 | 43 | echo "id,name,email,spent,proposed_discount_amount" > weekly_customer_discounts.csv 44 | 45 | ############################################################################### 46 | # 2. Propose a 30% discount amount if the spent >= 200 else 20%. 47 | ############################################################################### 48 | while IFS=$',' read -r id fn ln email spent; do 49 | rate=0.20; (( $(echo "$spent >= 200" | bc) )) && rate=0.30 50 | coupon=$(awk -v s="$spent" -v r="$rate" 'BEGIN{print int((s*r)+0.999)}') 51 | echo "\"$fn $ln\",$email,$spent,$coupon" >> weekly_customer_discounts.csv 52 | done <<< "$customers" 53 | 54 | ############################################################################### 55 | # 3. Print the result. 56 | ############################################################################### 57 | if [[ $(wc -l < weekly_customer_discounts.csv) -le 1 ]]; then 58 | echo "✅ No discounts proposed for any customers" 59 | else 60 | cat weekly_customer_discounts.csv 61 | fi 62 | -------------------------------------------------------------------------------- /internal/api/metafields.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/ankitpokhrel/shopctl/pkg/gql/client" 8 | "github.com/ankitpokhrel/shopctl/schema" 9 | ) 10 | 11 | // SetMetafields sets a metafield to the resource. 12 | func (c GQLClient) SetMetafields(metafields []schema.MetafieldsSetInput) (*MetafieldSetResponse, error) { 13 | var out struct { 14 | Data struct { 15 | MetafieldsSet MetafieldSetResponse `json:"metafieldsSet"` 16 | } `json:"data"` 17 | Errors Errors `json:"errors,omitempty"` 18 | } 19 | 20 | query := ` 21 | mutation metafieldsSet($metafields: [MetafieldsSetInput!]!) { 22 | metafieldsSet(metafields: $metafields) { 23 | metafields { 24 | id 25 | } 26 | userErrors { 27 | field 28 | message 29 | } 30 | } 31 | }` 32 | 33 | req := client.GQLRequest{ 34 | Query: query, 35 | Variables: client.QueryVars{ 36 | "metafields": metafields, 37 | }, 38 | } 39 | 40 | if err := c.Execute(context.Background(), req, nil, &out); err != nil { 41 | return nil, err 42 | } 43 | if len(out.Errors) > 0 { 44 | return nil, fmt.Errorf("productMetafieldsSet: the operation failed with error: %s", out.Errors.Error()) 45 | } 46 | if len(out.Data.MetafieldsSet.UserErrors) > 0 { 47 | return nil, fmt.Errorf("productMetafieldsSet: the operation failed with user error: %s", out.Data.MetafieldsSet.UserErrors.Error()) 48 | } 49 | return &out.Data.MetafieldsSet, nil 50 | } 51 | 52 | // DeleteMetafields deletes metafield attached to a resource. 53 | func (c GQLClient) DeleteMetafields(metafields []schema.MetafieldIdentifierInput) (*MetafieldDeleteResponse, error) { 54 | var out struct { 55 | Data struct { 56 | MetafieldsDelete MetafieldDeleteResponse `json:"metafieldsDelete"` 57 | } `json:"data"` 58 | Errors Errors `json:"errors,omitempty"` 59 | } 60 | 61 | query := ` 62 | mutation metafieldsDelete($metafields: [MetafieldIdentifierInput!]!) { 63 | metafieldsDelete(metafields: $metafields) { 64 | deletedMetafields { 65 | key 66 | namespace 67 | ownerId 68 | } 69 | userErrors { 70 | field 71 | message 72 | } 73 | } 74 | }` 75 | 76 | req := client.GQLRequest{ 77 | Query: query, 78 | Variables: client.QueryVars{ 79 | "metafields": metafields, 80 | }, 81 | } 82 | 83 | if err := c.Execute(context.Background(), req, nil, &out); err != nil { 84 | return nil, err 85 | } 86 | if len(out.Errors) > 0 { 87 | return nil, fmt.Errorf("productMetafieldsDelete: the operation failed with error: %s", out.Errors.Error()) 88 | } 89 | if len(out.Data.MetafieldsDelete.UserErrors) > 0 { 90 | return nil, fmt.Errorf("productMetafieldsDelete: the operation failed with user error: %s", out.Data.MetafieldsDelete.UserErrors.Error()) 91 | } 92 | return &out.Data.MetafieldsDelete, nil 93 | } 94 | -------------------------------------------------------------------------------- /internal/engine/backup.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "time" 10 | ) 11 | 12 | const ( 13 | modeDir = 0o755 14 | modeFile = 0o644 15 | ) 16 | 17 | // Backup is a backup engine. 18 | type Backup struct { 19 | id string 20 | store string 21 | root string 22 | dir string 23 | timestamp time.Time 24 | } 25 | 26 | // Option is a functional opt for Backup. 27 | type Option func(*Backup) 28 | 29 | // NewBackup creates a new backup engine. 30 | func NewBackup(store string, opts ...Option) *Backup { 31 | now := time.Now() 32 | id := genBackupID(store, now.Unix()) 33 | bkp := Backup{ 34 | id: id, 35 | store: store, 36 | root: os.TempDir(), 37 | timestamp: now, 38 | } 39 | 40 | for _, opt := range opts { 41 | opt(&bkp) 42 | } 43 | 44 | if bkp.dir == "" { 45 | bkp.dir = fmt.Sprintf("%s_%s", bkp.timestamp.Format("2006_01_02_15_04_05"), id) 46 | } 47 | bkp.root = filepath.Join(bkp.root, bkp.dir) 48 | 49 | return &bkp 50 | } 51 | 52 | // WithBackupRoot sets root backup dir. 53 | func WithBackupRoot(root string) Option { 54 | return func(b *Backup) { 55 | b.root = root 56 | } 57 | } 58 | 59 | // WithBackupDir sets backup dir name. 60 | func WithBackupDir(dir string) Option { 61 | return func(b *Backup) { 62 | b.dir = dir 63 | } 64 | } 65 | 66 | // Store returns the store this backup will run for. 67 | func (b *Backup) Store() string { 68 | return b.store 69 | } 70 | 71 | // Root returns root backup directory. 72 | func (b *Backup) Root() string { 73 | return b.root 74 | } 75 | 76 | // Dir returns backup directory name. 77 | func (b *Backup) Dir() string { 78 | return b.dir 79 | } 80 | 81 | // Do starts the backup process. 82 | // Implements `engine.Doer` interface. 83 | func (b *Backup) Do(rs Resource, _ any) (any, error) { 84 | dir := filepath.Join(b.root, rs.Path) 85 | if err := os.MkdirAll(dir, modeDir); err != nil { 86 | return nil, err 87 | } 88 | dest := filepath.Join(dir, rs.Type.File()) 89 | 90 | data, err := rs.Handler.Handle(nil) 91 | if err != nil { 92 | return nil, err 93 | } 94 | err = b.saveJSON(dest, data) 95 | return nil, err 96 | } 97 | 98 | // saveJSON saves data to a JSON file. 99 | func (b *Backup) saveJSON(path string, data any) error { 100 | var ( 101 | jsonData []byte 102 | err error 103 | ) 104 | 105 | if jsonData, err = json.Marshal(data); err != nil { 106 | return fmt.Errorf("failed to marshal JSON: %w", err) 107 | } 108 | 109 | file := fmt.Sprintf("%s.json", path) 110 | if err := os.WriteFile(file, jsonData, modeFile); err != nil { 111 | return fmt.Errorf("failed to write file: %w", err) 112 | } 113 | 114 | return nil 115 | } 116 | 117 | func genBackupID(store string, timestamp int64) string { 118 | data := fmt.Appendf(nil, "%s-%d", store, timestamp) 119 | hash := sha256.Sum256(data) 120 | return fmt.Sprintf("%x", hash[:5]) 121 | } 122 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package shopctl 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/ankitpokhrel/shopctl/schema" 9 | ) 10 | 11 | const ( 12 | AppConfigVersion = "v0" 13 | ShopifyApiVersion = "2025-04" 14 | ) 15 | 16 | // ShopifyProductID formats Shopify product ID. 17 | func ShopifyProductID(id string) string { 18 | prefix := "gid://shopify/Product" 19 | if strings.HasPrefix(id, prefix) { 20 | return id 21 | } 22 | if _, err := strconv.Atoi(id); err != nil { 23 | return "" // Not an integer id. 24 | } 25 | return fmt.Sprintf("%s/%s", prefix, id) 26 | } 27 | 28 | // ShopifyProductVariantID formats Shopify product variant ID. 29 | func ShopifyProductVariantID(id string) string { 30 | prefix := "gid://shopify/ProductVariant" 31 | if strings.HasPrefix(id, prefix) { 32 | return id 33 | } 34 | if _, err := strconv.Atoi(id); err != nil { 35 | return "" 36 | } 37 | return fmt.Sprintf("%s/%s", prefix, id) 38 | } 39 | 40 | // ShopifyMediaID formats Shopify product media ID. 41 | func ShopifyMediaID(id string, typ schema.MediaContentType) string { 42 | validPrefixes := map[schema.MediaContentType]string{ 43 | schema.MediaContentTypeImage: "gid://shopify/MediaImage", 44 | schema.MediaContentTypeVideo: "gid://shopify/Video", 45 | schema.MediaContentTypeModel3d: "gid://shopify/Model3d", 46 | schema.MediaContentTypeExternalVideo: "gid://shopify/ExternalVideo", 47 | } 48 | for _, p := range validPrefixes { 49 | if strings.HasPrefix(id, p) { 50 | return id 51 | } 52 | } 53 | 54 | prefix, ok := validPrefixes[typ] 55 | if !ok { 56 | return "" 57 | } 58 | if _, err := strconv.Atoi(id); err != nil { 59 | return "" 60 | } 61 | return fmt.Sprintf("%s/%s", prefix, id) 62 | } 63 | 64 | // ShopifyCustomerID formats Shopify customer ID. 65 | func ShopifyCustomerID(id string) string { 66 | prefix := "gid://shopify/Customer" 67 | if strings.HasPrefix(id, prefix) { 68 | return id 69 | } 70 | if _, err := strconv.Atoi(id); err != nil { 71 | return "" 72 | } 73 | return fmt.Sprintf("%s/%s", prefix, id) 74 | } 75 | 76 | // ShopifyOrderID formats Shopify order ID. 77 | func ShopifyOrderID(id string) string { 78 | prefix := "gid://shopify/Order" 79 | if strings.HasPrefix(id, prefix) { 80 | return id 81 | } 82 | if _, err := strconv.Atoi(id); err != nil { 83 | return "" 84 | } 85 | return fmt.Sprintf("%s/%s", prefix, id) 86 | } 87 | 88 | // ShopifyWebhookSubscriptionID formats Shopify product variant ID. 89 | func ShopifyWebhookSubscriptionID(id string) string { 90 | prefix := "gid://shopify/WebhookSubscription" 91 | if strings.HasPrefix(id, prefix) { 92 | return id 93 | } 94 | if _, err := strconv.Atoi(id); err != nil { 95 | return "" 96 | } 97 | return fmt.Sprintf("%s/%s", prefix, id) 98 | } 99 | 100 | // ExtractNumericID extracts numeric part of a Shopify ID. 101 | // Ex: gid://shopify/Product/8737842954464 -> 8737842954464. 102 | func ExtractNumericID(shopifyID string) string { 103 | parts := strings.Split(shopifyID, "/") 104 | return parts[len(parts)-1] 105 | } 106 | -------------------------------------------------------------------------------- /examples/scripts/discount_high_inventory.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################### 4 | # Script: discount_high_inventory.sh 5 | # 6 | # Scenario: 7 | # This script identifies high-inventory Shopify products recently updated, and 8 | # applies a conditional 10% discount to their variants. The goal is to optimize 9 | # pricing for overstocked items without compromising acceptable profit margins. 10 | # 11 | # 1. Scans for products updated in the last 24 hours with inventory_total ≥ 300. 12 | # 2. For each variant, calculates a 10% discounted price and applies it only if: 13 | # new_price ≥ unit_cost × 1.15 (to preserve at least 15% margin) 14 | # 3. Outputs a CSV summary of all discounted variants including old and new prices. 15 | # 4. If no discounts were applied, reports that and exits cleanly. 16 | # 17 | # Intended for use in automated price adjustment workflows and clearance routines. 18 | ############################################################################### 19 | 20 | set -euo pipefail 21 | export TZ="Europe/Berlin" 22 | 23 | DAY_START=$(date -d '1 day ago' +%Y-%m-%d) 24 | 25 | ############################################################################### 26 | # 1. Find products updated in the last 24 hours and has large inventory. 27 | ############################################################################### 28 | echo "🔍 Scanning products with high inventory since $DAY_START ..." 29 | products=$(shopctl product list "inventory_total:>=300" --updated=">=$DAY_START" --columns id,title --csv --no-headers) 30 | 31 | if [[ -z "$products" ]]; then 32 | echo "🟢 No high inventory updated products since $DAY_START — nothing to do" 33 | exit 0 34 | fi 35 | 36 | # Create CSV summary file 37 | echo "product_id,product_title,variant_id,old_price,new_price" > inventory_discounts.csv 38 | 39 | ############################################################################### 40 | # 2. Apply a 10% discount only if the resulting price is still ≥ cost × 1.15. 41 | ############################################################################### 42 | while IFS=',' read -r pid title; do 43 | variants=$(shopctl product variant list $pid --columns id,price,unit_cost --csv --no-headers) 44 | [[ -z "$variants" ]] && continue 45 | 46 | while IFS=',' read -r variant_id price unit_cost; do 47 | new_price=$(echo "scale=2; $price * 0.9" | bc) # 10% discount 48 | margin_ok=$(echo "$new_price >= ($unit_cost*1.15)" | bc) 49 | 50 | if [[ $margin_ok -eq 1 ]]; then 51 | shopctl product variant edit $pid --id $variant_id --price "$new_price" 52 | echo "$pid,\"$title\",$variant_id,$price,$new_price" >> inventory_discounts.csv 53 | fi 54 | done <<< "$variants" 55 | done <<< "$products" 56 | 57 | ############################################################################### 58 | # 3. Print the result. 59 | ############################################################################### 60 | if [[ $(wc -l < inventory_discounts.csv) -le 1 ]]; then 61 | echo "✅ No discounts added to any of the products" 62 | else 63 | cat inventory_discounts.csv 64 | fi 65 | 66 | -------------------------------------------------------------------------------- /pkg/tui/table/static.go: -------------------------------------------------------------------------------- 1 | package table 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "strings" 7 | 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/charmbracelet/lipgloss/table" 10 | ) 11 | 12 | // StaticTable constructs static table object. 13 | type StaticTable struct { 14 | table *table.Table 15 | colWidths []int 16 | noHeaders bool 17 | columns []string 18 | } 19 | 20 | // StaticTableOption is a functional opt for StaticTable. 21 | type StaticTableOption func(*StaticTable) 22 | 23 | // NewStaticTable builds a new static table. 24 | func NewStaticTable(cols []Column, rows []Row, opts ...StaticTableOption) *StaticTable { 25 | var ( 26 | headers []string 27 | widths []int 28 | keepCols []int 29 | ) 30 | 31 | t := StaticTable{noHeaders: true} 32 | for _, o := range opts { 33 | o(&t) 34 | } 35 | 36 | for i, c := range cols { 37 | if slices.Contains(t.columns, keyme(c.Title)) { 38 | headers = append(headers, c.Title) 39 | widths = append(widths, c.Width) 40 | keepCols = append(keepCols, i) 41 | } 42 | } 43 | 44 | contents := make([][]string, len(rows)) 45 | for i, row := range rows { 46 | var newRow []string 47 | for _, j := range keepCols { 48 | if j < len(row) { 49 | newRow = append(newRow, row[j]) 50 | } 51 | } 52 | contents[i] = newRow 53 | } 54 | 55 | tbl := table.New().Rows(contents...) 56 | if !t.noHeaders { 57 | tbl.Headers(headers...) 58 | } 59 | t.table = tbl 60 | t.colWidths = widths 61 | 62 | return &t 63 | } 64 | 65 | // WithNoHeaders sets noHeaders option. 66 | func WithNoHeaders(ok bool) StaticTableOption { 67 | return func(t *StaticTable) { 68 | t.noHeaders = ok 69 | } 70 | } 71 | 72 | // WithTableColumns sets columns to display. 73 | func WithTableColumns(cols []string) StaticTableOption { 74 | return func(t *StaticTable) { 75 | t.columns = cols 76 | } 77 | } 78 | 79 | // Render renders the final table. 80 | func (t *StaticTable) Render() error { 81 | gray := lipgloss.Color("245") 82 | headerStyle := lipgloss.NewStyle().Foreground(gray).Bold(true) 83 | cellStyle := lipgloss.NewStyle().Padding(0, 0) 84 | 85 | t.table. 86 | Border(lipgloss.HiddenBorder()). 87 | StyleFunc(func(row, col int) lipgloss.Style { 88 | if row == table.HeaderRow { 89 | return headerStyle 90 | } 91 | return cellStyle.Width(t.colWidths[col]) 92 | }) 93 | 94 | out := t.table.String() 95 | if t.noHeaders { 96 | out = clean(out) 97 | } 98 | _, err := fmt.Println(out) 99 | return err 100 | } 101 | 102 | func clean(input string) string { 103 | lines := strings.Split(input, "\n") 104 | 105 | // Remove first line if empty. 106 | if len(lines) > 0 && strings.TrimSpace(lines[0]) == "" { 107 | lines = lines[1:] 108 | } 109 | 110 | // Remove last line if empty. 111 | if len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { 112 | lines = lines[:len(lines)-1] 113 | } 114 | 115 | return strings.Join(lines, "\n") 116 | } 117 | 118 | func keyme(s string) string { 119 | s = strings.ToLower(s) 120 | return strings.ReplaceAll(s, " ", "_") 121 | } 122 | -------------------------------------------------------------------------------- /internal/cmd/customer/delete/delete.go: -------------------------------------------------------------------------------- 1 | package delete 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/ankitpokhrel/shopctl" 7 | "github.com/ankitpokhrel/shopctl/internal/api" 8 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 9 | ) 10 | 11 | const ( 12 | helpText = `Delete lets you delete a customer by ID, email or phone.` 13 | 14 | examples = `# Delete customer by its ID 15 | $ shopctl customer delete 8370159190 16 | $ shopctl customer delete gid://shopify/Customer/8370159190 17 | 18 | # Delete customer by its email 19 | $ shopctl customer delete --email example@domain.com 20 | 21 | # Delete customer by its phone number 22 | $ shopctl customer delete --phone +1234567890` 23 | ) 24 | 25 | type flag struct { 26 | id string 27 | email string 28 | phone string 29 | } 30 | 31 | func (f *flag) parse(cmd *cobra.Command, args []string) { 32 | email, err := cmd.Flags().GetString("email") 33 | cmdutil.ExitOnErr(err) 34 | 35 | phone, err := cmd.Flags().GetString("phone") 36 | cmdutil.ExitOnErr(err) 37 | 38 | if len(args) > 0 { 39 | f.id = shopctl.ShopifyCustomerID(args[0]) 40 | } 41 | f.email = email 42 | f.phone = phone 43 | 44 | if f.id == "" && f.email == "" && f.phone == "" { 45 | cmdutil.ExitOnErr( 46 | cmdutil.HelpErrorf("Either a valid id, email or phone of the customer to delete is required.", examples), 47 | ) 48 | } 49 | } 50 | 51 | // NewCmdDelete constructs a new customer create command. 52 | func NewCmdDelete() *cobra.Command { 53 | cmd := cobra.Command{ 54 | Use: "delete [CUSTOMER_ID]", 55 | Short: "Delete a customer", 56 | Long: helpText, 57 | Example: examples, 58 | Aliases: []string{"del", "rm", "remove"}, 59 | Annotations: map[string]string{ 60 | "help:args": `CUSTOMER_ID full or numeric Customer ID, eg: 88561444456 or gid://shopify/Customer/88561444456`, 61 | }, 62 | RunE: func(cmd *cobra.Command, args []string) error { 63 | client := cmd.Context().Value(cmdutil.KeyGQLClient).(*api.GQLClient) 64 | 65 | cmdutil.ExitOnErr(run(cmd, args, client)) 66 | return nil 67 | }, 68 | } 69 | cmd.Flags().StringP("email", "e", "", "The email address of the customer") 70 | cmd.Flags().StringP("phone", "p", "", "The phone number of the customer") 71 | 72 | cmd.Flags().SortFlags = false 73 | 74 | return &cmd 75 | } 76 | 77 | func run(cmd *cobra.Command, args []string, client *api.GQLClient) error { 78 | flag := &flag{} 79 | flag.parse(cmd, args) 80 | 81 | var customerID string 82 | 83 | switch { 84 | case flag.id != "": 85 | customerID = flag.id 86 | case flag.email != "": 87 | customer, err := client.CheckCustomerByEmailOrPhoneOrID(&flag.email, nil, "") 88 | if err != nil { 89 | return err 90 | } 91 | customerID = customer.ID 92 | case flag.phone != "": 93 | customer, err := client.CheckCustomerByEmailOrPhoneOrID(nil, &flag.phone, "") 94 | if err != nil { 95 | return err 96 | } 97 | customerID = customer.ID 98 | } 99 | 100 | _, err := client.DeleteCustomer(customerID) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | cmdutil.Success("Customer deleted successfully: %s", customerID) 106 | return nil 107 | } 108 | -------------------------------------------------------------------------------- /internal/engine/resource.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | const ( 4 | Product ResourceType = "product" 5 | ProductOption ResourceType = "product_option" 6 | ProductVariant ResourceType = "product_variant" 7 | ProductMedia ResourceType = "product_media" 8 | ProductMetaField ResourceType = "product_metafield" 9 | Customer ResourceType = "customer" 10 | CustomerMetaField ResourceType = "customer_metafield" 11 | ) 12 | 13 | // ResourceType represents a type of a resource to backup. 14 | type ResourceType string 15 | 16 | // File returns the file name for the backup based on resource type. 17 | func (r ResourceType) File() string { 18 | switch r { 19 | case Product: 20 | return "product" 21 | case ProductOption: 22 | return "product" 23 | case ProductVariant: 24 | return "product_variants" 25 | case ProductMedia: 26 | return "product_media" 27 | case ProductMetaField: 28 | return "product_metafields" 29 | case Customer: 30 | return "customer" 31 | case CustomerMetaField: 32 | return "customer_metafields" 33 | } 34 | panic("unknown resource type") 35 | } 36 | 37 | // RootDir returns a root level dir for the resource type. 38 | func (r ResourceType) RootDir() string { 39 | switch r { 40 | case Product: 41 | return "products" 42 | case Customer: 43 | return "customers" 44 | } 45 | panic("unknown root resource type") 46 | } 47 | 48 | // IsPrimary checks if the resource type is primary. 49 | func (r ResourceType) IsPrimary() bool { 50 | return r == Product || r == Customer 51 | } 52 | 53 | // ResourceHandler is a handler for a resource. 54 | type ResourceHandler interface { 55 | Handle(data any) (any, error) 56 | } 57 | 58 | // Resource represents a backup resource. 59 | type Resource struct { 60 | Type ResourceType 61 | Path string 62 | Handler ResourceHandler 63 | } 64 | 65 | // NewResource constructs a new backup resource. 66 | func NewResource(rt ResourceType, path string, rh ResourceHandler) Resource { 67 | return Resource{ 68 | Type: rt, 69 | Path: path, 70 | Handler: rh, 71 | } 72 | } 73 | 74 | // ResourceCollection is a collection of resources. 75 | type ResourceCollection struct { 76 | Parent *Resource 77 | Children []Resource 78 | } 79 | 80 | // GetPrimaryResourceTypes returns primary resource types. 81 | func GetPrimaryResourceTypes() []ResourceType { 82 | return []ResourceType{ 83 | Product, 84 | Customer, 85 | } 86 | } 87 | 88 | // GetProductResourceTypes returns product resource types in order. 89 | func GetProductResourceTypes() []ResourceType { 90 | return []ResourceType{ 91 | Product, 92 | ProductOption, 93 | ProductVariant, 94 | ProductMetaField, 95 | ProductMedia, 96 | } 97 | } 98 | 99 | // GetCustomerResourceTypes returns all resource types in order. 100 | func GetCustomerResourceTypes() []ResourceType { 101 | return []ResourceType{ 102 | Customer, 103 | CustomerMetaField, 104 | } 105 | } 106 | 107 | // GetAllResourceTypes returns all resource types in order. 108 | func GetAllResourceTypes() []ResourceType { 109 | return []ResourceType{ 110 | Product, 111 | ProductOption, 112 | ProductVariant, 113 | ProductMetaField, 114 | ProductMedia, 115 | Customer, 116 | CustomerMetaField, 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /internal/cmd/product/peek/peek.go: -------------------------------------------------------------------------------- 1 | package peek 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/ankitpokhrel/shopctl" 10 | "github.com/ankitpokhrel/shopctl/internal/api" 11 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 12 | "github.com/ankitpokhrel/shopctl/internal/config" 13 | "github.com/ankitpokhrel/shopctl/internal/registry" 14 | "github.com/ankitpokhrel/shopctl/schema" 15 | ) 16 | 17 | const ( 18 | helpText = `Peek lets you peek into the product data. 19 | 20 | Use this command to quickly look into the upstream or local product data.` 21 | 22 | examples = `# Peek by id 23 | $ shopctl peek product 24 | 25 | # Peek a product from the import folder 26 | # Context and strategy is skipped for direct path 27 | $ shopctl peek product --from 28 | 29 | # Render json output 30 | $ shopctl peek product --json` 31 | ) 32 | 33 | // Flag wraps available command flags. 34 | type flag struct { 35 | id string 36 | from string 37 | json bool 38 | } 39 | 40 | func (f *flag) parse(cmd *cobra.Command, args []string) { 41 | id := shopctl.ShopifyProductID(args[0]) 42 | if id == "" { 43 | cmdutil.ExitOnErr(fmt.Errorf("invalid product id")) 44 | } 45 | 46 | from, err := cmd.Flags().GetString("from") 47 | cmdutil.ExitOnErr(err) 48 | 49 | jsonOut, err := cmd.Flags().GetBool("json") 50 | cmdutil.ExitOnErr(err) 51 | 52 | f.id = id 53 | f.from = from 54 | f.json = jsonOut 55 | } 56 | 57 | // NewCmdPeek creates a new product restore command. 58 | // TODO: Implement `--from` option. 59 | func NewCmdPeek() *cobra.Command { 60 | cmd := cobra.Command{ 61 | Use: "peek PRODUCT_ID", 62 | Short: "Peek into product data", 63 | Long: helpText, 64 | Example: examples, 65 | Args: cobra.MinimumNArgs(1), 66 | Aliases: []string{"view"}, 67 | RunE: func(cmd *cobra.Command, args []string) error { 68 | ctx := cmd.Context().Value(cmdutil.KeyContext).(*config.StoreContext) 69 | client := cmd.Context().Value(cmdutil.KeyGQLClient).(*api.GQLClient) 70 | 71 | cmdutil.ExitOnErr(run(cmd, args, ctx, client)) 72 | return nil 73 | }, 74 | } 75 | cmd.Flags().StringP("from", "f", "", "Direct path to the backup to look into") 76 | cmd.Flags().Bool("json", false, "Output in JSON format") 77 | 78 | return &cmd 79 | } 80 | 81 | func run(cmd *cobra.Command, args []string, ctx *config.StoreContext, client *api.GQLClient) error { 82 | var ( 83 | product *schema.Product 84 | reg *registry.Registry 85 | err error 86 | ) 87 | 88 | flag := &flag{} 89 | flag.parse(cmd, args) 90 | 91 | if flag.from != "" { 92 | reg, err = registry.NewRegistry(flag.from) 93 | if err != nil { 94 | return err 95 | } 96 | product, err = reg.GetProductByID(shopctl.ExtractNumericID(flag.id)) 97 | } else { 98 | product, err = client.GetProductByID(flag.id) 99 | } 100 | if err != nil { 101 | return err 102 | } 103 | 104 | if flag.json { 105 | s, err := json.MarshalIndent(product, "", " ") 106 | if err != nil { 107 | return err 108 | } 109 | return cmdutil.PagerOut(string(s)) 110 | } 111 | 112 | // Convert to Markdown. 113 | r := NewFormatter(ctx.Store, product) 114 | return r.Render() 115 | } 116 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | 10 | "github.com/knadh/koanf/parsers/yaml" 11 | "github.com/knadh/koanf/providers/file" 12 | "github.com/knadh/koanf/v2" 13 | yamlv3 "gopkg.in/yaml.v3" 14 | ) 15 | 16 | const ( 17 | rootDir = "shopctl" 18 | 19 | fileTypeYaml = "yml" 20 | fileTypeJson = "json" 21 | 22 | modeDir = 0o755 23 | modeFile = 0o644 24 | modeOwner = 0o700 25 | ) 26 | 27 | var ( 28 | // ErrConfigExist is thrown if the config file already exist. 29 | ErrConfigExist = fmt.Errorf("config already exist") 30 | // ErrNoConfig is thrown if a config file couldn't be found. 31 | ErrNoConfig = fmt.Errorf("config doesn't exist") 32 | // ErrActionAborted is thrown if a user cancels an action. 33 | ErrActionAborted = fmt.Errorf("action aborted") 34 | ) 35 | 36 | type config struct { 37 | writer *koanf.Koanf 38 | kind string 39 | dir string 40 | name string 41 | path string 42 | } 43 | 44 | //nolint:unparam 45 | func newConfig(dir, name, kind string) (*config, error) { 46 | cfgFile := filepath.Join(dir, fmt.Sprintf("%s.%s", name, kind)) 47 | 48 | if err := ensureConfigFile(dir, cfgFile, false); err != nil && !errors.Is(err, ErrConfigExist) { 49 | return nil, err 50 | } 51 | 52 | cfg := config{ 53 | kind: kind, 54 | dir: dir, 55 | name: name, 56 | path: cfgFile, 57 | } 58 | 59 | w, err := loadConfig(cfgFile) 60 | if err != nil { 61 | return nil, err 62 | } 63 | cfg.writer = w 64 | 65 | return &cfg, nil 66 | } 67 | 68 | // Path is a config file path. 69 | func (c config) Path() string { 70 | return c.path 71 | } 72 | 73 | // home returns dir for the config. 74 | func home() string { 75 | if home := os.Getenv("SHOPIFY_CONFIG_HOME"); home != "" { 76 | return filepath.Join(home, rootDir) 77 | } 78 | if home := os.Getenv("XDG_CONFIG_HOME"); home != "" { 79 | return filepath.Join(home, rootDir) 80 | } 81 | if home := os.Getenv("AppData"); runtime.GOOS == "windows" && home != "" { 82 | return filepath.Join(home, "ShopCTL") 83 | } 84 | 85 | home, _ := os.UserHomeDir() 86 | return filepath.Join(home, ".config", rootDir) 87 | } 88 | 89 | func loadConfig(cfgFile string) (*koanf.Koanf, error) { 90 | k := koanf.New(".") 91 | f := file.Provider(cfgFile) 92 | 93 | if err := k.Load(f, yaml.Parser()); err != nil { 94 | return nil, err 95 | } 96 | return k, nil 97 | } 98 | 99 | func writeYAML(cfgFile string, data any) error { 100 | bytes, err := yamlv3.Marshal(data) 101 | if err != nil { 102 | return err 103 | } 104 | return os.WriteFile(cfgFile, bytes, modeFile) 105 | } 106 | 107 | func exists(file string) bool { 108 | if _, err := os.Stat(file); os.IsNotExist(err) { 109 | return false 110 | } 111 | return true 112 | } 113 | 114 | func ensureConfigFile(dir string, cfgFile string, force bool) error { 115 | // Bail early if config already exists. 116 | if !force && exists(cfgFile) { 117 | return ErrConfigExist 118 | } 119 | 120 | // Create top-level dir. 121 | if _, err := os.Stat(dir); os.IsNotExist(err) { 122 | if err := os.MkdirAll(dir, modeOwner); err != nil { 123 | return err 124 | } 125 | } 126 | 127 | // Create config file. 128 | f, err := os.Create(cfgFile) 129 | if err != nil { 130 | return err 131 | } 132 | return f.Close() 133 | } 134 | -------------------------------------------------------------------------------- /examples/scripts/validate_recent_products.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################### 4 | # Script: validate_recent_products.sh 5 | # 6 | # Scenario: 7 | # This script validates recently updated Shopify products to ensure variant data 8 | # quality. It performs the following checks: 9 | # 10 | # 1. Scans for up to 100 products updated in the last 24 hours. 11 | # 2. For each product, iterates through its variants and flags: 12 | # - Empty SKUs 13 | # - Prices outside the accepted range ($25–$100) 14 | # 3. Outputs a CSV report of invalid variants with reasons for failure. 15 | # 4. If any invalid variants are found, prints the report and exits with error. 16 | # 17 | # Intended for CI/CD pipelines or scheduled validation jobs to catch common 18 | # catalog issues before publishing or syncing data. 19 | ############################################################################### 20 | 21 | set -euo pipefail 22 | export TZ="Europe/Berlin" 23 | 24 | DAY_START=$(date -d '1 day ago' +%Y-%m-%d) 25 | 26 | # Helper that appends a validation message to the global `reason` variable. 27 | add_reason() { 28 | if [[ -z "$reason" ]]; then 29 | reason="$1" 30 | else 31 | reason+="; $1" 32 | fi 33 | } 34 | 35 | ############################################################################### 36 | # 1. Get 100 products that changed in the last 24h. 37 | ############################################################################### 38 | echo "🔍 Scanning products updated since $DAY_START ..." 39 | products=$(shopctl product list --columns id,status --updated ">=$DAY_START" --csv --no-headers --limit 100) 40 | 41 | if [[ -z "$products" ]]; then 42 | echo "🟢 No products updated since $DAY_START — nothing to validate" 43 | exit 0 44 | fi 45 | 46 | echo "product_id,variant_id,variant_title,sku,price,status,reason" > invalid_products.csv 47 | invalid=0 48 | 49 | ############################################################################### 50 | # 2. For each product, check variants for empty SKU OR price out of range. 51 | ############################################################################### 52 | while IFS=',' read -r id status; do 53 | pid="gid://shopify/Product/${id}" 54 | 55 | variants=$(shopctl product variant list "$pid" --columns id,title,sku,price --csv --no-headers) 56 | [[ -z "$variants" ]] && continue 57 | 58 | while IFS=',' read -r vid_raw title sku price; do 59 | vid="gid://shopify/ProductVariant/${vid_raw}" 60 | reason="" 61 | 62 | [[ -z "$sku" ]] && add_reason "Empty SKU" 63 | 64 | out_of_range=$(echo "$price < 25 || $price > 100" | bc || true) 65 | [[ $out_of_range -eq 1 ]] && add_reason "Price $price out of \$25–\$100" 66 | 67 | if [[ -n "$reason" ]]; then 68 | { 69 | printf '%s,%s,"%s",%s,%s,%s,%s\n' \ 70 | "$pid" "$vid" "$title" "$sku" "$price" "$status" "$reason" 71 | } >> invalid_products.csv 72 | invalid=$((invalid + 1)) 73 | fi 74 | done <<< "$variants" 75 | done <<< "$products" 76 | 77 | ############################################################################### 78 | # 3. Print the result. 79 | ############################################################################### 80 | if (( invalid )); then 81 | echo "::error::${invalid} invalid variant(s) found" 82 | cat invalid_products.csv 83 | exit 1 84 | else 85 | echo "✅ All checked variants passed" 86 | fi 87 | 88 | -------------------------------------------------------------------------------- /internal/cmd/product/media/attach/attach.go: -------------------------------------------------------------------------------- 1 | package attach 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/ankitpokhrel/shopctl" 11 | "github.com/ankitpokhrel/shopctl/internal/api" 12 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 13 | "github.com/ankitpokhrel/shopctl/schema" 14 | ) 15 | 16 | const ( 17 | helpText = `Attach product media from a publicly accessible source.` 18 | 19 | examples = `$ shopctl product media attach 8856145494 --url "https://example.com/file.png" --alt "File attached from the CLI" 20 | $ shopctl product media attach 8856145494 --url "https://youtu.be/dQw4w9WgXcQ" --media-type EXTERNAL_VIDEO` 21 | ) 22 | 23 | // Flag wraps available command flags. 24 | type flag struct { 25 | id string 26 | url string 27 | alt string 28 | typ schema.MediaContentType 29 | } 30 | 31 | func (f *flag) parse(cmd *cobra.Command, args []string) { 32 | id := args[0] 33 | 34 | url, err := cmd.Flags().GetString("url") 35 | cmdutil.ExitOnErr(err) 36 | 37 | if url == "" { 38 | cmdutil.ExitOnErr(cmdutil.HelpErrorf("Link to the media is required", examples)) 39 | } 40 | 41 | alt, err := cmd.Flags().GetString("alt") 42 | cmdutil.ExitOnErr(err) 43 | 44 | mediaType, err := cmd.Flags().GetString("media-type") 45 | cmdutil.ExitOnErr(err) 46 | mediaType = strings.ToUpper(mediaType) 47 | 48 | validMediaTypes := []string{ 49 | string(schema.MediaContentTypeImage), 50 | string(schema.MediaContentTypeVideo), 51 | string(schema.MediaContentTypeExternalVideo), 52 | string(schema.MediaContentTypeModel3d), 53 | } 54 | if mediaType != "" && !slices.Contains(validMediaTypes, mediaType) { 55 | cmdutil.ExitOnErr(cmdutil.HelpErrorf( 56 | fmt.Sprintf("Media type must be one of: %s", strings.Join(validMediaTypes, ", ")), examples), 57 | ) 58 | } 59 | 60 | f.id = shopctl.ShopifyProductID(id) 61 | f.url = url 62 | f.alt = alt 63 | f.typ = schema.MediaContentType(mediaType) 64 | } 65 | 66 | // NewCmdAttach constructs a new product attach command. 67 | func NewCmdAttach() *cobra.Command { 68 | cmd := cobra.Command{ 69 | Use: "attach PRODUCT_ID", 70 | Short: "Attach product media", 71 | Long: helpText, 72 | Example: examples, 73 | Args: cobra.MinimumNArgs(1), 74 | Aliases: []string{"link"}, 75 | RunE: func(cmd *cobra.Command, args []string) error { 76 | client := cmd.Context().Value(cmdutil.KeyGQLClient).(*api.GQLClient) 77 | 78 | cmdutil.ExitOnErr(run(cmd, args, client)) 79 | return nil 80 | }, 81 | } 82 | cmd.Flags().StringP("url", "l", "", "Link to a publicly accessible media") 83 | cmd.Flags().StringP("alt", "a", "", "Alt text for the media") 84 | cmd.Flags().StringP("media-type", "t", "IMAGE", "Media content type; one of: IMAGE, VIDEO, EXTERNAL_VIDEO, MODEL_3D") 85 | 86 | return &cmd 87 | } 88 | 89 | func run(cmd *cobra.Command, args []string, client *api.GQLClient) error { 90 | flag := &flag{} 91 | flag.parse(cmd, args) 92 | 93 | input := schema.ProductInput{ 94 | ID: &flag.id, 95 | } 96 | createMediaInput := schema.CreateMediaInput{ 97 | OriginalSource: flag.url, 98 | Alt: &flag.alt, 99 | MediaContentType: flag.typ, 100 | } 101 | 102 | res, err := client.UpdateProduct(input, []schema.CreateMediaInput{createMediaInput}) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | cmdutil.Success("Media attached successfully to product: %s", res.Product.ID) 108 | return nil 109 | } 110 | -------------------------------------------------------------------------------- /internal/cmd/root/help.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/kr/text" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | type helpEntry struct { 13 | Title string 14 | Body string 15 | } 16 | 17 | //nolint:errcheck 18 | func helpFunc(cmd *cobra.Command, _ []string) { 19 | entries := getEntries(cmd) 20 | 21 | out := cmd.OutOrStdout() 22 | for _, e := range entries { 23 | if e.Title != "" { 24 | fmt.Fprintf(out, "\033[1m%s\033[0m\n", e.Title) 25 | fmt.Fprintln(out, text.Indent(strings.Trim(e.Body, "\r\n"), " ")) 26 | } else { 27 | fmt.Fprintln(out, e.Body) 28 | } 29 | fmt.Fprintln(out) 30 | } 31 | } 32 | 33 | func getEntries(cmd *cobra.Command) []helpEntry { 34 | mainCmds, otherCmds := groupCommands(cmd.Commands()) 35 | 36 | var entries []helpEntry 37 | 38 | appendIfNotEmpty := func(b any, t string) { 39 | switch d := b.(type) { 40 | case string: 41 | if b != "" { 42 | entries = append(entries, helpEntry{Title: t, Body: d}) 43 | } 44 | case []string: 45 | if len(d) > 0 { 46 | entries = append(entries, helpEntry{Title: t, Body: strings.Join(d, "\n")}) 47 | } 48 | } 49 | } 50 | 51 | if cmd.Long != "" { 52 | entries = append(entries, helpEntry{"", cmd.Long}) 53 | } else if cmd.Short != "" { 54 | entries = append(entries, helpEntry{"", cmd.Short}) 55 | } 56 | 57 | appendIfNotEmpty(cmd.UseLine(), "USAGE") 58 | appendIfNotEmpty(mainCmds, "MAIN COMMANDS") 59 | appendIfNotEmpty(otherCmds, "OTHER COMMANDS") 60 | appendIfNotEmpty(outdent(cmd.LocalFlags().FlagUsages()), "FLAGS") 61 | appendIfNotEmpty(outdent(cmd.InheritedFlags().FlagUsages()), "INHERITED FLAGS") 62 | if _, ok := cmd.Annotations["help:args"]; ok { 63 | appendIfNotEmpty(cmd.Annotations["help:args"], "ARGUMENTS") 64 | } 65 | appendIfNotEmpty(cmd.Example, "EXAMPLES") 66 | appendIfNotEmpty(cmd.Aliases, "ALIASES") 67 | entries = append(entries, helpEntry{ 68 | "LEARN MORE", 69 | `Use 'shopctl --help' for more information about a command. 70 | Read the doc or get help at https://github.com/ankitpokhrel/shopctl`, 71 | }) 72 | 73 | return entries 74 | } 75 | 76 | func groupCommands(cmds []*cobra.Command) ([]string, []string) { 77 | var primary, secondary []string 78 | 79 | for _, c := range cmds { 80 | if c.Short == "" { 81 | continue 82 | } 83 | if c.Hidden { 84 | continue 85 | } 86 | 87 | s := rpad(c.Name(), c.NamePadding()) + c.Short 88 | if _, ok := c.Annotations["cmd:main"]; ok { 89 | primary = append(primary, s) 90 | } else { 91 | secondary = append(secondary, s) 92 | } 93 | } 94 | 95 | if len(primary) == 0 { 96 | primary = secondary 97 | secondary = []string{} 98 | } 99 | 100 | return primary, secondary 101 | } 102 | 103 | func outdent(s string) string { 104 | lines, minIndent := strings.Split(s, "\n"), -1 105 | 106 | for _, l := range lines { 107 | if l == "" { 108 | continue 109 | } 110 | 111 | indent := len(l) - len(strings.TrimLeft(l, " ")) 112 | if minIndent == -1 || indent < minIndent { 113 | minIndent = indent 114 | } 115 | } 116 | 117 | if minIndent <= 0 { 118 | return s 119 | } 120 | 121 | var buf bytes.Buffer 122 | for _, l := range lines { 123 | fmt.Fprintln(&buf, strings.TrimPrefix(l, strings.Repeat(" ", minIndent))) 124 | } 125 | return strings.TrimSuffix(buf.String(), "\n") 126 | } 127 | 128 | func rpad(s string, pad int) string { 129 | template := fmt.Sprintf("%%-%ds ", pad) 130 | return fmt.Sprintf(template, s) 131 | } 132 | -------------------------------------------------------------------------------- /pkg/gql/introspect/ops.go: -------------------------------------------------------------------------------- 1 | package introspect 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // GetIntrospectionTypes extracts GQL type definition from OBJECT and INPUT_FIELDS. 8 | func GetIntrospectionTypes(schema IntrospectionSchema) map[string]TypeRef { 9 | ofTypes := make(map[string]TypeRef) 10 | 11 | parseField := func(fieldType TypeRef) { 12 | if fieldType.OfType != nil { 13 | if fieldType.OfType.Name != "" { 14 | ofTypes[fieldType.OfType.Name] = *fieldType.OfType 15 | } else if fieldType.Name != "" { 16 | ofTypes[fieldType.Name] = *fieldType.OfType 17 | } 18 | } else if fieldType.Name != "" { 19 | ofTypes[fieldType.Name] = fieldType 20 | } 21 | } 22 | 23 | for _, gqlType := range schema.Types { 24 | for _, field := range gqlType.Fields { 25 | parseField(field.Type) 26 | } 27 | for _, field := range gqlType.InputFields { 28 | parseField(field.Type) 29 | } 30 | } 31 | 32 | return ofTypes 33 | } 34 | 35 | // gqlTypeToGoType converts a GraphQL type to a Go type. 36 | func gqlTypeToGoType(ref TypeRef) string { 37 | switch ref.Kind { 38 | case SCALAR: 39 | switch ref.Name { 40 | case "Int": 41 | return "int" 42 | case "Float": 43 | fallthrough 44 | case "Decimal": 45 | return "float64" 46 | case "String": 47 | return "string" 48 | case "Boolean": 49 | return "bool" 50 | case "ID": 51 | return "string" 52 | default: 53 | return "string" 54 | } 55 | case OBJECT: 56 | return ref.Name 57 | case NON_NULL: 58 | if ref.OfType != nil { 59 | return gqlTypeToGoType(*ref.OfType) 60 | } 61 | return ref.Name 62 | case NULL: 63 | return gqlTypeToGoType(*ref.OfType) 64 | case LIST: 65 | if ref.OfType != nil && ref.OfType.Name != "" { 66 | return "[]" + gqlTypeToGoType(*ref.OfType) 67 | } 68 | return "[]any" 69 | case ENUM: 70 | return ref.Name 71 | case INPUT_OBJECT: 72 | return ref.Name 73 | case INTERFACE: 74 | return ref.Name 75 | case UNION: 76 | return "any" 77 | } 78 | return "any" // Default fallback type. 79 | } 80 | 81 | // Convert a string to camel case, with the first letter capitalized and special cases handled. 82 | func capitalize(input string) string { 83 | if input == "" { 84 | return input 85 | } 86 | 87 | // Special cases. 88 | special := map[string]string{ 89 | "id": "ID", 90 | "uri": "URI", 91 | "url": "URL", 92 | "api": "API", 93 | } 94 | 95 | // Exceptions. 96 | exceptions := []string{ 97 | "valid", 98 | "invalid", 99 | "liquid", 100 | } 101 | 102 | hasSuffix := func(s, suffix string) bool { return strings.HasSuffix(strings.ToLower(s), suffix) } 103 | toUpper := func(s string) string { return strings.ToUpper(s[:1]) + s[1:] } 104 | capitalizeWord := func(word string) string { 105 | // Bail early if we find an exact special word. 106 | if val, ok := special[word]; ok { 107 | return val 108 | } 109 | 110 | // Ignore replacement if suffix is in the exception list. 111 | for _, v := range exceptions { 112 | if len(word) >= len(v) && hasSuffix(word, v) { 113 | return word 114 | } 115 | } 116 | 117 | // If the input ends with special suffix, replace it with the defined value. 118 | for k, v := range special { 119 | if len(word) > len(k) && hasSuffix(word, k) { 120 | return word[:len(word)-len(k)] + v 121 | } 122 | } 123 | 124 | return word 125 | } 126 | 127 | words := strings.Split(input, "_") 128 | for i, word := range words { 129 | words[i] = toUpper(capitalizeWord(word)) 130 | } 131 | return strings.Join(words, "") 132 | } 133 | -------------------------------------------------------------------------------- /internal/runner/backup/customer/customer.go: -------------------------------------------------------------------------------- 1 | package customer 2 | 3 | import ( 4 | "path/filepath" 5 | "time" 6 | 7 | "github.com/ankitpokhrel/shopctl" 8 | "github.com/ankitpokhrel/shopctl/internal/api" 9 | "github.com/ankitpokhrel/shopctl/internal/engine" 10 | "github.com/ankitpokhrel/shopctl/internal/runner" 11 | "github.com/ankitpokhrel/shopctl/internal/runner/backup/customer/provider" 12 | "github.com/ankitpokhrel/shopctl/pkg/tlog" 13 | ) 14 | 15 | const batchSize = 250 16 | 17 | // Runner is a customer backup runner. 18 | type Runner struct { 19 | eng *engine.Engine 20 | bkpEng *engine.Backup 21 | client *api.GQLClient 22 | logger *tlog.Logger 23 | stats map[engine.ResourceType]*runner.Summary 24 | } 25 | 26 | // NewRunner constructs a new backup runner. 27 | func NewRunner(eng *engine.Engine, client *api.GQLClient, logger *tlog.Logger) *Runner { 28 | bkpEng := eng.Doer().(*engine.Backup) 29 | 30 | stats := make(map[engine.ResourceType]*runner.Summary) 31 | for _, rt := range engine.GetCustomerResourceTypes() { 32 | stats[rt] = &runner.Summary{} 33 | } 34 | 35 | return &Runner{ 36 | eng: eng, 37 | bkpEng: bkpEng, 38 | client: client, 39 | logger: logger, 40 | stats: stats, 41 | } 42 | } 43 | 44 | // Kind returns runner type; implements `runner.Runner` interface. 45 | func (r *Runner) Kind() engine.ResourceType { 46 | return engine.Customer 47 | } 48 | 49 | // Stats returns runner stats. 50 | func (r *Runner) Stats() map[engine.ResourceType]*runner.Summary { 51 | return r.stats 52 | } 53 | 54 | // Run executes customer backup; implements `runner.Runner` interface. 55 | func (r *Runner) Run() error { 56 | r.eng.Register(engine.Customer) 57 | backupStart := time.Now() 58 | 59 | go func() { 60 | defer r.eng.Done(engine.Customer) 61 | r.backup(batchSize, nil) 62 | }() 63 | 64 | for res := range r.eng.Run(engine.Customer) { 65 | if res.Err != nil { 66 | r.stats[res.ResourceType].Failed += 1 67 | r.logger.Errorf("Failed to export resource %s: %v\n", res.ResourceType, res.Err) 68 | } else if res.ResourceType == engine.Customer { 69 | r.stats[res.ResourceType].Passed += 1 70 | } 71 | } 72 | 73 | r.logger.V(tlog.VL3).Infof( 74 | "Customer export complete in %s", 75 | time.Since(backupStart), 76 | ) 77 | return nil 78 | } 79 | 80 | func (r *Runner) backup(limit int, after *string) { 81 | customersCh := make(chan *api.CustomersResponse, batchSize) 82 | 83 | go func() { 84 | defer close(customersCh) 85 | 86 | if err := r.client.GetAllCustomers(customersCh, limit, after); err != nil { 87 | r.logger.Error("error when fetching customres", "limit", limit, "after", after, "error", err) 88 | } 89 | }() 90 | 91 | for customers := range customersCh { 92 | r.stats[r.Kind()].Count += len(customers.Data.Customers.Nodes) 93 | 94 | for _, customer := range customers.Data.Customers.Nodes { 95 | cid := shopctl.ExtractNumericID(customer.ID) 96 | 97 | path := filepath.Join(engine.Customer.RootDir(), cid) 98 | r.logger.V(tlog.VL2).Infof("Customer %s: registering export to path %s/%s", cid, r.bkpEng.Dir(), path) 99 | 100 | customerFn := &provider.Customer{Customer: &customer} 101 | metafieldFn := &provider.MetaField{Client: r.client, Logger: r.logger, CustomerID: customer.ID} 102 | 103 | parent := engine.NewResource(engine.Customer, path, customerFn) 104 | 105 | r.eng.Add(engine.Customer, engine.ResourceCollection{ 106 | Parent: &parent, 107 | Children: []engine.Resource{ 108 | engine.NewResource(engine.CustomerMetaField, path, metafieldFn), 109 | }, 110 | }) 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /internal/cmd/product/media/detach/detach.go: -------------------------------------------------------------------------------- 1 | package detach 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/ankitpokhrel/shopctl" 11 | "github.com/ankitpokhrel/shopctl/internal/api" 12 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 13 | "github.com/ankitpokhrel/shopctl/schema" 14 | ) 15 | 16 | const ( 17 | helpText = `Detach one or more media from the product.` 18 | 19 | examples = `# Detach media of type IMAGE from a product 20 | $ shopctl product media detach 8856145494 365299811616 21 | 22 | # Detach multiple media of same type from a product 23 | $ shopctl product media detach 8856145494 365299811616 365299811617 365299811618 -tVIDEO 24 | 25 | # Detach multiple media of different type from a product 26 | $ shopctl product media detach 8856145494 gid://shopify/MediaImage/365299811616 gid://shopify/Video/365299811617` 27 | ) 28 | 29 | type flag struct { 30 | productID string 31 | mediaID []string 32 | mediaType schema.MediaContentType 33 | } 34 | 35 | func (f *flag) parse(cmd *cobra.Command, args []string) { 36 | mediaType, err := cmd.Flags().GetString("media-type") 37 | cmdutil.ExitOnErr(err) 38 | 39 | validMediaTypes := []string{ 40 | string(schema.MediaContentTypeImage), 41 | string(schema.MediaContentTypeVideo), 42 | string(schema.MediaContentTypeExternalVideo), 43 | string(schema.MediaContentTypeModel3d), 44 | } 45 | if mediaType != "" && !slices.Contains(validMediaTypes, mediaType) { 46 | cmdutil.ExitOnErr(cmdutil.HelpErrorf( 47 | fmt.Sprintf("Media type must be one of: %s", strings.Join(validMediaTypes, ", ")), examples), 48 | ) 49 | } 50 | 51 | f.mediaType = schema.MediaContentType(mediaType) 52 | f.productID = shopctl.ShopifyProductID(args[0]) 53 | 54 | f.mediaID = make([]string, 0, len(args[1:])) 55 | for _, id := range args[1:] { 56 | mid := shopctl.ShopifyMediaID(id, f.mediaType) 57 | f.mediaID = append(f.mediaID, mid) 58 | } 59 | } 60 | 61 | // NewCmdDetach constructs a new product attach command. 62 | func NewCmdDetach() *cobra.Command { 63 | cmd := cobra.Command{ 64 | Use: "detach PRODUCT_ID MEDIA_ID...", 65 | Short: "Detach one or more media from the product", 66 | Long: helpText, 67 | Example: examples, 68 | Args: cobra.MinimumNArgs(2), 69 | Aliases: []string{"unlink"}, 70 | Annotations: map[string]string{ 71 | "help:args": `PRODUCT_ID Shopify full or numeric Product ID, eg: 8856145494 or gid://shopify/Product/8856145494 72 | MEDIA_ID List of Shopify full or numeric Media IDs, eg: 365299811616 or gid://shopify/MediaImage/365299811616`, 73 | }, 74 | RunE: func(cmd *cobra.Command, args []string) error { 75 | client := cmd.Context().Value(cmdutil.KeyGQLClient).(*api.GQLClient) 76 | 77 | cmdutil.ExitOnErr(run(cmd, args, client)) 78 | return nil 79 | }, 80 | } 81 | cmd.Flags().StringP("media-type", "t", "IMAGE", "Media content type; one of: IMAGE, VIDEO, EXTERNAL_VIDEO, MODEL_3D") 82 | 83 | return &cmd 84 | } 85 | 86 | func run(cmd *cobra.Command, args []string, client *api.GQLClient) error { 87 | flag := &flag{} 88 | flag.parse(cmd, args) 89 | 90 | input := make([]schema.FileUpdateInput, 0, len(flag.mediaID)) 91 | for _, id := range flag.mediaID { 92 | input = append(input, schema.FileUpdateInput{ 93 | ID: id, 94 | ReferencesToRemove: []any{flag.productID}, 95 | }) 96 | } 97 | 98 | _, err := client.DetachProductMedia(input) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | cmdutil.Success("Media detached successfully from product: %s", flag.productID) 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /internal/cmd/product/option/add/add.go: -------------------------------------------------------------------------------- 1 | package add 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/ankitpokhrel/shopctl" 7 | "github.com/ankitpokhrel/shopctl/internal/api" 8 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 9 | "github.com/ankitpokhrel/shopctl/schema" 10 | ) 11 | 12 | const ( 13 | helpText = `Add product options.` 14 | 15 | examples = `$ shopctl product option add 8856145494 --name Title --value "Special product" 16 | 17 | # Option with multiple values 18 | $ shopctl product option add 8856145494 -nSize -lxs -lsm -lxl 19 | 20 | # Set variant strategy to CREATE; default is LEAVE_AS_IS 21 | # With '--create' flag, existing variants are updated with the first option value 22 | # See https://shopify.dev/docs/api/admin-graphql/latest/enums/ProductOptionCreateVariantStrategy 23 | $ shopctl product option add 8856145494 -nStyle -lCasual -lInformal --create` 24 | ) 25 | 26 | // Flag wraps available command flags. 27 | type flag struct { 28 | id string 29 | name string 30 | position *int 31 | values []string 32 | create schema.ProductOptionCreateVariantStrategy 33 | } 34 | 35 | func (f *flag) parse(cmd *cobra.Command, args []string) { 36 | var ( 37 | position *int 38 | err error 39 | ) 40 | 41 | id := args[0] 42 | 43 | name, err := cmd.Flags().GetString("name") 44 | cmdutil.ExitOnErr(err) 45 | 46 | if name == "" { 47 | cmdutil.ExitOnErr(cmdutil.HelpErrorf("Product option name cannot be blank", examples)) 48 | } 49 | 50 | posFlag := cmd.Flags().Lookup("position") 51 | if posFlag != nil && posFlag.Changed { 52 | pos, err := cmd.Flags().GetInt("position") 53 | cmdutil.ExitOnErr(err) 54 | position = &pos 55 | } 56 | 57 | values, err := cmd.Flags().GetStringArray("value") 58 | cmdutil.ExitOnErr(err) 59 | 60 | create, err := cmd.Flags().GetBool("create") 61 | cmdutil.ExitOnErr(err) 62 | 63 | strategy := schema.ProductOptionCreateVariantStrategyLeaveAsIs 64 | if create { 65 | strategy = schema.ProductOptionCreateVariantStrategyCreate 66 | } 67 | 68 | f.id = shopctl.ShopifyProductID(id) 69 | f.name = name 70 | f.position = position 71 | f.values = values 72 | f.create = strategy 73 | } 74 | 75 | // NewCmdAdd constructs a new product option add command. 76 | func NewCmdAdd() *cobra.Command { 77 | cmd := cobra.Command{ 78 | Use: "add PRODUCT_ID", 79 | Short: "Add product options", 80 | Long: helpText, 81 | Example: examples, 82 | Args: cobra.MinimumNArgs(1), 83 | Aliases: []string{"create"}, 84 | RunE: func(cmd *cobra.Command, args []string) error { 85 | client := cmd.Context().Value(cmdutil.KeyGQLClient).(*api.GQLClient) 86 | 87 | cmdutil.ExitOnErr(run(cmd, args, client)) 88 | return nil 89 | }, 90 | } 91 | cmd.Flags().StringP("name", "n", "", "Option name") 92 | cmd.Flags().IntP("position", "p", 0, "Option position") 93 | cmd.Flags().StringArrayP("value", "l", []string{}, "Option values") 94 | cmd.Flags().Bool("create", false, "Option create variant strategy") 95 | 96 | return &cmd 97 | } 98 | 99 | func run(cmd *cobra.Command, args []string, client *api.GQLClient) error { 100 | flag := &flag{} 101 | flag.parse(cmd, args) 102 | 103 | opt := schema.OptionCreateInput{ 104 | Name: &flag.name, 105 | Position: flag.position, 106 | Values: make([]any, 0, len(flag.values)), 107 | } 108 | for _, v := range flag.values { 109 | opt.Values = append(opt.Values, schema.OptionValueCreateInput{ 110 | Name: &v, 111 | }) 112 | } 113 | 114 | res, err := client.CreateProductOptions(flag.id, []schema.OptionCreateInput{opt}, flag.create) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | cmdutil.Success("Option added successfully to product: %s", res.Product.ID) 120 | return nil 121 | } 122 | -------------------------------------------------------------------------------- /internal/runner/backup/product/product.go: -------------------------------------------------------------------------------- 1 | package product 2 | 3 | import ( 4 | "path/filepath" 5 | "time" 6 | 7 | "github.com/ankitpokhrel/shopctl" 8 | "github.com/ankitpokhrel/shopctl/internal/api" 9 | "github.com/ankitpokhrel/shopctl/internal/engine" 10 | "github.com/ankitpokhrel/shopctl/internal/runner" 11 | "github.com/ankitpokhrel/shopctl/internal/runner/backup/product/provider" 12 | "github.com/ankitpokhrel/shopctl/pkg/tlog" 13 | ) 14 | 15 | const batchSize = 250 16 | 17 | // Runner is a product backup runner. 18 | type Runner struct { 19 | eng *engine.Engine 20 | bkpEng *engine.Backup 21 | client *api.GQLClient 22 | filter *string 23 | logger *tlog.Logger 24 | stats map[engine.ResourceType]*runner.Summary 25 | } 26 | 27 | // NewRunner constructs a new backup runner. 28 | func NewRunner(eng *engine.Engine, client *api.GQLClient, filter string, logger *tlog.Logger) *Runner { 29 | bkpEng := eng.Doer().(*engine.Backup) 30 | 31 | var f *string 32 | if filter != "" { 33 | f = &filter 34 | } 35 | 36 | stats := make(map[engine.ResourceType]*runner.Summary) 37 | for _, rt := range engine.GetProductResourceTypes() { 38 | stats[rt] = &runner.Summary{} 39 | } 40 | 41 | return &Runner{ 42 | eng: eng, 43 | bkpEng: bkpEng, 44 | client: client, 45 | filter: f, 46 | logger: logger, 47 | stats: stats, 48 | } 49 | } 50 | 51 | // Kind returns runner type; implements `runner.Runner` interface. 52 | func (r *Runner) Kind() engine.ResourceType { 53 | return engine.Product 54 | } 55 | 56 | // Stats returns runner stats. 57 | func (r *Runner) Stats() map[engine.ResourceType]*runner.Summary { 58 | return r.stats 59 | } 60 | 61 | // Run executes product backup; implements `runner.Runner` interface. 62 | func (r *Runner) Run() error { 63 | r.eng.Register(engine.Product) 64 | backupStart := time.Now() 65 | 66 | go func() { 67 | defer r.eng.Done(engine.Product) 68 | r.backup(batchSize, nil, r.filter) 69 | }() 70 | 71 | for res := range r.eng.Run(engine.Product) { 72 | if res.Err != nil { 73 | r.stats[res.ResourceType].Failed += 1 74 | r.logger.Errorf("Failed to export resource %s: %v\n", res.ResourceType, res.Err) 75 | } else if res.ResourceType == engine.Product { 76 | r.stats[res.ResourceType].Passed += 1 77 | } 78 | } 79 | 80 | r.logger.V(tlog.VL3).Infof( 81 | "Product export complete in %s", 82 | time.Since(backupStart), 83 | ) 84 | return nil 85 | } 86 | 87 | func (r *Runner) backup(limit int, after *string, query *string) { 88 | productsCh := make(chan *api.ProductsResponse, batchSize) 89 | 90 | go func() { 91 | defer close(productsCh) 92 | 93 | if err := r.client.GetAllProducts(productsCh, limit, after, query); err != nil { 94 | r.logger.Error("Failed to fetch products", "limit", limit, "after", after, "error", err) 95 | } 96 | }() 97 | 98 | for products := range productsCh { 99 | r.stats[r.Kind()].Count += len(products.Data.Products.Edges) 100 | 101 | for _, product := range products.Data.Products.Edges { 102 | pid := shopctl.ExtractNumericID(product.Node.ID) 103 | 104 | path := filepath.Join(engine.Product.RootDir(), pid) 105 | r.logger.V(tlog.VL2).Infof("Product %s: registering export to path %s/%s", pid, r.bkpEng.Dir(), path) 106 | 107 | productFn := &provider.Product{Product: &product.Node} 108 | variantFn := &provider.Variant{Client: r.client, Logger: r.logger, ProductID: product.Node.ID} 109 | mediaFn := &provider.Media{Client: r.client, Logger: r.logger, ProductID: product.Node.ID} 110 | metafieldFn := &provider.MetaField{Client: r.client, Logger: r.logger, ProductID: product.Node.ID} 111 | 112 | parent := engine.NewResource(engine.Product, path, productFn) 113 | 114 | r.eng.Add(engine.Product, engine.ResourceCollection{ 115 | Parent: &parent, 116 | Children: []engine.Resource{ 117 | engine.NewResource(engine.ProductVariant, path, variantFn), 118 | engine.NewResource(engine.ProductMedia, path, mediaFn), 119 | engine.NewResource(engine.ProductMetaField, path, metafieldFn), 120 | }, 121 | }) 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /pkg/tlog/log.go: -------------------------------------------------------------------------------- 1 | package tlog 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log/slog" 7 | "time" 8 | 9 | "go.uber.org/zap" 10 | "go.uber.org/zap/exp/zapslog" 11 | "go.uber.org/zap/zapcore" 12 | ) 13 | 14 | // VerboseLevel is a log verbosity. 15 | type VerboseLevel int32 16 | 17 | const ( 18 | // VL0 (VerboseLevel zero) is a default logging verbosity. 19 | VL0 VerboseLevel = iota 20 | // VL1 (VerboseLevel one) is the minimum logging verbosity. 21 | VL1 22 | // VL2 (VerboseLevel two) is an intermediate level of logging verbosity. 23 | VL2 24 | // VL3 (VerboseLevel three) provides the highest level of logging verbosity. 25 | VL3 26 | ) 27 | 28 | var noopLogger = slog.New(zapslog.NewHandler(zapcore.NewNopCore())) 29 | 30 | // Logger is an app logger. 31 | type Logger struct { 32 | writer *slog.Logger 33 | errWriter *slog.Logger 34 | verbosity VerboseLevel 35 | } 36 | 37 | // newConsole builds a sensible production Logger that writes InfoLevel and 38 | // above logs to standard error as text. 39 | // 40 | // Logging is enabled at InfoLevel and above, and uses a console encoder. 41 | // Logs are written to standard error. 42 | // Stacktraces are included on logs of ErrorLevel and above. 43 | // DPanicLevel logs will not panic, but will write a stacktrace. 44 | func newConsole(options ...zap.Option) (*zap.Logger, error) { 45 | encoder := zapcore.EncoderConfig{ 46 | TimeKey: "ts", 47 | LevelKey: "level", 48 | NameKey: "logger", 49 | CallerKey: "caller", 50 | FunctionKey: zapcore.OmitKey, 51 | MessageKey: "msg", 52 | LineEnding: zapcore.DefaultLineEnding, 53 | EncodeLevel: zapcore.CapitalColorLevelEncoder, 54 | EncodeTime: zapcore.TimeEncoderOfLayout(time.RFC3339), 55 | EncodeDuration: zapcore.SecondsDurationEncoder, 56 | EncodeCaller: zapcore.ShortCallerEncoder, 57 | } 58 | 59 | config := zap.Config{ 60 | Level: zap.NewAtomicLevelAt(zap.InfoLevel), 61 | Development: false, 62 | DisableStacktrace: true, 63 | Sampling: nil, 64 | Encoding: "console", 65 | EncoderConfig: encoder, 66 | OutputPaths: []string{"stderr"}, 67 | ErrorOutputPaths: []string{"stderr"}, 68 | } 69 | 70 | return config.Build(options...) 71 | } 72 | 73 | // New constructs a new logger. 74 | func New(v VerboseLevel, quiet bool) *Logger { 75 | zapLogger := zap.Must(newConsole()) 76 | defer func() { _ = zapLogger.Sync() }() 77 | 78 | w := slog.New(zapslog.NewHandler(zapLogger.Sugar().Desugar().Core())) 79 | 80 | var logger *slog.Logger 81 | if quiet { 82 | logger = slog.New(slog.NewTextHandler(io.Discard, nil)) 83 | } else { 84 | logger = w 85 | } 86 | return &Logger{ 87 | writer: logger, 88 | errWriter: w, 89 | verbosity: v, 90 | } 91 | } 92 | 93 | // V checks the verbosity level and returns the logger instance if verbosity is sufficient. 94 | func (l *Logger) V(level VerboseLevel) *Logger { 95 | if l.verbosity >= level { 96 | return l 97 | } 98 | return &Logger{writer: noopLogger} 99 | } 100 | 101 | // Info logs informational messages. 102 | func (l *Logger) Info(msg string, args ...any) { 103 | l.writer.Info(msg, args...) 104 | } 105 | 106 | // Infof logs formatted informational messages. 107 | func (l *Logger) Infof(format string, args ...any) { 108 | l.writer.Info(fmt.Sprintf(format, args...)) 109 | } 110 | 111 | // Warn logs warning messages. 112 | func (l *Logger) Warn(msg string, args ...any) { 113 | l.writer.Warn(msg, args...) 114 | } 115 | 116 | // Warnf logs formatted warning messages. 117 | func (l *Logger) Warnf(msg string, args ...any) { 118 | l.writer.Warn(fmt.Sprintf(msg, args...)) 119 | } 120 | 121 | // Error logs error messages. 122 | func (l *Logger) Error(msg string, args ...any) { 123 | l.errWriter.Error(msg, args...) 124 | } 125 | 126 | // Errorf logs formatted error messages. 127 | func (l *Logger) Errorf(msg string, args ...any) { 128 | l.errWriter.Error(fmt.Sprintf(msg, args...)) 129 | } 130 | 131 | // Debug logs debug messages for verbosity level >= VL3. 132 | func (l *Logger) Debug(msg string, args ...any) { 133 | l.V(VL3).writer.Debug(msg, args...) 134 | } 135 | 136 | // Debugf logs formated debug messages for verbosity level >= VL3. 137 | func (l *Logger) Debugf(msg string, args ...any) { 138 | l.V(VL3).writer.Debug(fmt.Sprintf(msg, args...)) 139 | } 140 | -------------------------------------------------------------------------------- /internal/runner/restore/product/handler/media.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/ankitpokhrel/shopctl/internal/api" 8 | "github.com/ankitpokhrel/shopctl/internal/registry" 9 | "github.com/ankitpokhrel/shopctl/internal/runner" 10 | "github.com/ankitpokhrel/shopctl/pkg/tlog" 11 | "github.com/ankitpokhrel/shopctl/schema" 12 | ) 13 | 14 | type Media struct { 15 | Client *api.GQLClient 16 | Logger *tlog.Logger 17 | File registry.File 18 | Summary *runner.Summary 19 | DryRun bool 20 | } 21 | 22 | func (h *Media) Handle(data any) (any, error) { 23 | var realProductID string 24 | if id, ok := data.(string); ok { 25 | realProductID = id 26 | } else { 27 | return nil, fmt.Errorf("unable to figure out real product ID") 28 | } 29 | mediaRaw, err := registry.ReadFileContents(h.File.Path) 30 | if err != nil { 31 | h.Logger.Error("Unable to read contents", "file", h.File.Path, "error", err) 32 | return nil, err 33 | } 34 | h.Summary.Count += 1 35 | 36 | var media api.ProductMediaData 37 | if err = json.Unmarshal(mediaRaw, &media); err != nil { 38 | h.Logger.Error("Unable to marshal contents", "file", h.File.Path, "error", err) 39 | h.Summary.Failed += 1 40 | return nil, err 41 | } 42 | 43 | toAdd := make([]*api.ProductMediaNode, 0) 44 | toDelete := make([]string, 0) 45 | 46 | // Get upstream medias. 47 | currentMedia, _ := h.Client.GetProductMedias(realProductID) 48 | currentMediaMap := make(map[string]*api.ProductMediaNode, 0) 49 | if currentMedia != nil { 50 | for _, m := range currentMedia.Data.Product.Media.Nodes { 51 | currentMediaMap[m.ID] = &m 52 | } 53 | 54 | backupMediaMap := make(map[string]*api.ProductMediaNode, len(media.Media.Nodes)) 55 | for _, m := range media.Media.Nodes { 56 | backupMediaMap[m.ID] = &m 57 | } 58 | 59 | for id := range currentMediaMap { 60 | if _, ok := backupMediaMap[id]; !ok { 61 | toDelete = append(toDelete, id) 62 | } 63 | } 64 | for id, m := range backupMediaMap { 65 | if _, ok := currentMediaMap[id]; !ok { 66 | toAdd = append(toAdd, m) 67 | } 68 | } 69 | } else { 70 | for _, m := range media.Media.Nodes { 71 | toAdd = append(toAdd, &m) 72 | } 73 | } 74 | 75 | attemptSync := func(pid string) error { 76 | if _, err := h.handleProductMediaDelete(pid, toDelete); err != nil { 77 | return err 78 | } 79 | if _, err := h.handleProductMediaAdd(pid, toAdd); err != nil { 80 | return err 81 | } 82 | return nil 83 | } 84 | 85 | h.Logger.V(tlog.VL2).Info("Attempting to attach product medias", "id", realProductID) 86 | if h.DryRun { 87 | h.Logger.V(tlog.VL2).Infof("Product media to sync - add: %d, remove: %d", len(toAdd), len(toDelete)) 88 | h.Logger.V(tlog.VL3).Warn("Skipping product media sync") 89 | h.Summary.Passed += 1 90 | return nil, nil 91 | } 92 | err = attemptSync(realProductID) 93 | if err != nil { 94 | h.Logger.Error("Failed to sync product medias", "oldID", media.ProductID, "upstreamID", realProductID) 95 | h.Summary.Failed += 1 96 | return nil, err 97 | } 98 | h.Summary.Passed += 1 99 | return nil, nil 100 | } 101 | 102 | func (h Media) handleProductMediaAdd(productID string, toAdd []*api.ProductMediaNode) (*api.ProductCreateResponse, error) { 103 | input := schema.ProductInput{ 104 | ID: &productID, 105 | } 106 | 107 | createMediaInput := make([]schema.CreateMediaInput, 0, len(toAdd)) 108 | for _, m := range toAdd { 109 | createMediaInput = append(createMediaInput, schema.CreateMediaInput{ 110 | OriginalSource: m.Preview.Image.URL, 111 | Alt: m.Preview.Image.AltText, 112 | MediaContentType: m.MediaContentType, 113 | }) 114 | } 115 | return h.Client.UpdateProduct(input, createMediaInput) 116 | } 117 | 118 | func (h Media) handleProductMediaDelete(productID string, toDelete []string) (*api.FileUpdateResponse, error) { 119 | if len(toDelete) == 0 { 120 | return nil, nil 121 | } 122 | 123 | input := make([]schema.FileUpdateInput, 0, len(toDelete)) 124 | for _, id := range toDelete { 125 | input = append(input, schema.FileUpdateInput{ 126 | ID: id, 127 | ReferencesToRemove: []any{productID}, 128 | }) 129 | } 130 | h.Logger.V(tlog.VL2).Info("Attempting to detach product medias", "id", productID) 131 | return h.Client.DetachProductMedia(input) 132 | } 133 | -------------------------------------------------------------------------------- /internal/cmdutil/tui_test.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestPad(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | input string 13 | length int 14 | expected string 15 | }{ 16 | { 17 | name: "it pads the string with spaces", 18 | input: "test", 19 | length: 10, 20 | expected: "test ", 21 | }, 22 | { 23 | name: "it returns the string as is", 24 | input: "test", 25 | length: 4, 26 | expected: "test", 27 | }, 28 | } 29 | 30 | for _, tc := range tests { 31 | tc := tc 32 | 33 | t.Run(tc.name, func(t *testing.T) { 34 | t.Parallel() 35 | 36 | assert.Equal(t, tc.expected, Pad(tc.input, tc.length)) 37 | }) 38 | } 39 | } 40 | 41 | func TestShortenAndPad(t *testing.T) { 42 | tests := []struct { 43 | name string 44 | input string 45 | length int 46 | expected string 47 | }{ 48 | { 49 | name: "it shortens the string and pads with ellipsis", 50 | input: "this is a long string", 51 | length: 10, 52 | expected: "this is a…", 53 | }, 54 | { 55 | name: "it returns the string as is", 56 | input: "short text", 57 | length: 10, 58 | expected: "short text", 59 | }, 60 | } 61 | 62 | for _, tc := range tests { 63 | tc := tc 64 | 65 | t.Run(tc.name, func(t *testing.T) { 66 | t.Parallel() 67 | 68 | assert.Equal(t, tc.expected, ShortenAndPad(tc.input, tc.length)) 69 | }) 70 | } 71 | } 72 | 73 | func TestIsDumbTerminal(t *testing.T) { 74 | { 75 | t.Setenv("TERM", "") 76 | assert.True(t, IsDumbTerminal()) 77 | } 78 | 79 | { 80 | t.Setenv("TERM", "dumb") 81 | assert.True(t, IsDumbTerminal()) 82 | } 83 | 84 | { 85 | t.Setenv("TERM", "foo") 86 | assert.False(t, IsDumbTerminal()) 87 | } 88 | 89 | { 90 | t.Setenv("WT_SESSION", "foo") 91 | assert.False(t, IsDumbTerminal()) 92 | } 93 | } 94 | 95 | func TestGetEditor(t *testing.T) { 96 | // SHOPIFY_EDITOR and VISUAL is set. 97 | { 98 | t.Setenv("SHOPIFY_EDITOR", "nvim") 99 | t.Setenv("VISUAL", "nano") 100 | 101 | editor, args := GetEditor() 102 | assert.Equal(t, "nvim", editor) 103 | assert.Empty(t, args) 104 | 105 | t.Setenv("SHOPIFY_EDITOR", "") 106 | t.Setenv("VISUAL", "") 107 | } 108 | 109 | // VISUAL is set. 110 | { 111 | t.Setenv("VISUAL", "nvim -n") 112 | 113 | editor, args := GetEditor() 114 | assert.Equal(t, "nvim", editor) 115 | assert.Equal(t, []string{"-n"}, args) 116 | 117 | t.Setenv("VISUAL", "") 118 | } 119 | 120 | // EDITOR is set. 121 | { 122 | t.Setenv("EDITOR", "nano") 123 | 124 | editor, args := GetEditor() 125 | assert.Equal(t, "nano", editor) 126 | assert.Empty(t, args) 127 | 128 | t.Setenv("EDITOR", "") 129 | } 130 | 131 | // Env not set. 132 | { 133 | editor, args := GetEditor() 134 | assert.Equal(t, "/usr/bin/vim", editor) 135 | assert.Empty(t, args) 136 | } 137 | } 138 | 139 | func TestGetPager(t *testing.T) { 140 | // TERM is xterm, SHOPIFY_PAGER is not set, PAGER is set. 141 | { 142 | t.Setenv("TERM", "xterm") 143 | 144 | t.Setenv("PAGER", "") 145 | assert.Equal(t, "less", GetPager()) 146 | 147 | t.Setenv("PAGER", "more") 148 | assert.Equal(t, "more", GetPager()) 149 | 150 | t.Setenv("PAGER", "") 151 | } 152 | 153 | // TERM is set, SHOPIFY_PAGER is not set, PAGER is unset. 154 | { 155 | t.Setenv("TERM", "dumb") 156 | assert.Equal(t, "cat", GetPager()) 157 | 158 | t.Setenv("TERM", "") 159 | assert.Equal(t, "cat", GetPager()) 160 | 161 | t.Setenv("TERM", "xterm") 162 | assert.Equal(t, "less", GetPager()) 163 | } 164 | 165 | // TERM is set, SHOPIFY_PAGER is set, PAGER is unset. 166 | { 167 | t.Setenv("SHOPIFY_PAGER", "bat") 168 | 169 | t.Setenv("TERM", "dumb") 170 | assert.Equal(t, "cat", GetPager()) 171 | 172 | t.Setenv("TERM", "") 173 | assert.Equal(t, "cat", GetPager()) 174 | 175 | t.Setenv("TERM", "xterm") 176 | assert.Equal(t, "bat", GetPager()) 177 | } 178 | 179 | // TERM gets precedence if both PAGER and TERM are set. 180 | { 181 | t.Setenv("TERM", "") 182 | t.Setenv("PAGER", "") 183 | t.Setenv("SHOPIFY_PAGER", "") 184 | assert.Equal(t, "cat", GetPager()) 185 | 186 | t.Setenv("PAGER", "more") 187 | t.Setenv("TERM", "dumb") 188 | assert.Equal(t, "cat", GetPager()) 189 | 190 | t.Setenv("PAGER", "more") 191 | t.Setenv("TERM", "xterm") 192 | assert.Equal(t, "more", GetPager()) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /pkg/search/search.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Query is a Shopify search query builder. 9 | type Query struct { 10 | conditions []string 11 | } 12 | 13 | // New returns a new instance of Query. 14 | func New() *Query { 15 | return &Query{ 16 | conditions: []string{}, 17 | } 18 | } 19 | 20 | // Add appends a raw condition to the query. 21 | func (q *Query) Add(condition string) *Query { 22 | q.conditions = append(q.conditions, condition) 23 | return q 24 | } 25 | 26 | // Eq adds an equality condition for a field. 27 | func (q *Query) Eq(field, value string) *Query { 28 | condition := fmt.Sprintf("%s:%s", field, escape(value)) 29 | q.conditions = append(q.conditions, condition) 30 | return q 31 | } 32 | 33 | // Neq adds a not-equal condition (using a minus prefix) for a field. 34 | func (q *Query) Neq(field, value string) *Query { 35 | condition := fmt.Sprintf("-%s:%s", field, escape(value)) 36 | q.conditions = append(q.conditions, condition) 37 | return q 38 | } 39 | 40 | // Gt adds a greater-than condition. 41 | // For example, Gt("price", 10) produces "price:>10". 42 | func (q *Query) Gt(field string, value any) *Query { 43 | condition := fmt.Sprintf("%s:>%v", field, value) 44 | q.conditions = append(q.conditions, condition) 45 | return q 46 | } 47 | 48 | // Lt adds a less-than condition. 49 | func (q *Query) Lt(field string, value any) *Query { 50 | condition := fmt.Sprintf("%s:<%v", field, value) 51 | q.conditions = append(q.conditions, condition) 52 | return q 53 | } 54 | 55 | // Gte adds a greater-than-or-equal condition. 56 | func (q *Query) Gte(field string, value any) *Query { 57 | condition := fmt.Sprintf("%s:>=%v", field, value) 58 | q.conditions = append(q.conditions, condition) 59 | return q 60 | } 61 | 62 | // Lte adds a less-than-or-equal condition. 63 | func (q *Query) Lte(field string, value any) *Query { 64 | condition := fmt.Sprintf("%s:<=%v", field, value) 65 | q.conditions = append(q.conditions, condition) 66 | return q 67 | } 68 | 69 | // Contains adds a condition that checks if a field contains the given substring. 70 | // It simulates a “contains” search by wrapping the value with wildcards. 71 | func (q *Query) Contains(field, value string) *Query { 72 | condition := fmt.Sprintf("%s:*%s*", field, value) 73 | q.conditions = append(q.conditions, condition) 74 | return q 75 | } 76 | 77 | // In adds a condition that checks if a field matches any one of the provided values. 78 | // It groups the OR conditions together. For example, In("product_type", "shirt", "sweater") 79 | // produces: (product_type:shirt OR product_type:sweater) 80 | func (q *Query) In(field string, values ...string) *Query { 81 | parts := make([]string, 0, len(values)) 82 | for _, v := range values { 83 | parts = append(parts, fmt.Sprintf("%s:%s", field, escape(v))) 84 | } 85 | group := fmt.Sprintf("(%s)", strings.Join(parts, " OR ")) 86 | q.conditions = append(q.conditions, group) 87 | return q 88 | } 89 | 90 | // InAnd is same as In() but groups conditions with AND instead of OR. 91 | func (q *Query) InAnd(field string, values ...string) *Query { 92 | parts := make([]string, 0, len(values)) 93 | for _, v := range values { 94 | parts = append(parts, fmt.Sprintf("%s:%s", field, escape(v))) 95 | } 96 | group := fmt.Sprintf("(%s)", strings.Join(parts, " AND ")) 97 | q.conditions = append(q.conditions, group) 98 | return q 99 | } 100 | 101 | // And adds an explicit AND operator. 102 | func (q *Query) And() *Query { 103 | q.conditions = append(q.conditions, "AND") 104 | return q 105 | } 106 | 107 | // Or adds an explicit OR operator. 108 | func (q *Query) Or() *Query { 109 | q.conditions = append(q.conditions, "OR") 110 | return q 111 | } 112 | 113 | // Group groups conditions together by accepting a function that builds a sub-query. 114 | // The grouped conditions are wrapped in parentheses. 115 | func (q *Query) Group(fn func(sub *Query)) *Query { 116 | subQuery := New() 117 | fn(subQuery) 118 | group := fmt.Sprintf("(%s)", subQuery.Build()) 119 | q.conditions = append(q.conditions, group) 120 | return q 121 | } 122 | 123 | // Build constructs the final query string. 124 | func (q *Query) Build() string { 125 | return strings.TrimSpace(strings.Join(q.conditions, " ")) 126 | } 127 | 128 | // escape checks if the value contains whitespace and, if so, 129 | // wraps it in quotes. It also escapes any inner double quotes. 130 | func escape(value string) string { 131 | if strings.ContainsAny(value, " \t") { 132 | escaped := strings.ReplaceAll(value, "\"", "\\\"") 133 | return fmt.Sprintf("\"%s\"", escaped) 134 | } 135 | return value 136 | } 137 | -------------------------------------------------------------------------------- /internal/runner/restore/product/handler/metafields.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/ankitpokhrel/shopctl/internal/api" 8 | "github.com/ankitpokhrel/shopctl/internal/registry" 9 | "github.com/ankitpokhrel/shopctl/internal/runner" 10 | "github.com/ankitpokhrel/shopctl/pkg/tlog" 11 | "github.com/ankitpokhrel/shopctl/schema" 12 | ) 13 | 14 | type Metafield struct { 15 | Client *api.GQLClient 16 | Logger *tlog.Logger 17 | File registry.File 18 | Summary *runner.Summary 19 | DryRun bool 20 | } 21 | 22 | func (h Metafield) Handle(data any) (any, error) { 23 | var realProductID string 24 | if id, ok := data.(string); ok { 25 | realProductID = id 26 | } else { 27 | return nil, fmt.Errorf("unable to figure out real product ID") 28 | } 29 | 30 | metaRaw, err := registry.ReadFileContents(h.File.Path) 31 | if err != nil { 32 | h.Logger.Error("Unable to read contents", "file", h.File.Path, "error", err) 33 | return nil, err 34 | } 35 | h.Summary.Count += 1 36 | 37 | var meta api.ProductMetafieldsData 38 | if err = json.Unmarshal(metaRaw, &meta); err != nil { 39 | h.Logger.Error("Unable to marshal contents", "file", h.File.Path, "error", err) 40 | h.Summary.Failed += 1 41 | return nil, err 42 | } 43 | 44 | toAdd := make([]*schema.Metafield, 0) 45 | toDelete := make([]*schema.Metafield, 0) 46 | 47 | // Get upstream metafields. 48 | currentMetafields, _ := h.Client.GetProductMetaFields(realProductID) 49 | if currentMetafields != nil { 50 | currentMetafieldsMap := make(map[string]*schema.Metafield, len(currentMetafields.Data.Product.Metafields.Nodes)) 51 | for _, opt := range currentMetafields.Data.Product.Metafields.Nodes { 52 | currentMetafieldsMap[opt.ID] = &opt 53 | } 54 | 55 | backupMetafieldsMap := make(map[string]*schema.Metafield, len(meta.Metafields.Nodes)) 56 | for _, m := range meta.Metafields.Nodes { 57 | backupMetafieldsMap[m.ID] = &m 58 | } 59 | 60 | for id, cm := range currentMetafieldsMap { 61 | if m, ok := backupMetafieldsMap[id]; ok { 62 | toAdd = append(toAdd, m) 63 | } else { 64 | toDelete = append(toDelete, cm) 65 | } 66 | } 67 | for id, m := range backupMetafieldsMap { 68 | if _, ok := currentMetafieldsMap[id]; !ok { 69 | toAdd = append(toAdd, m) 70 | } 71 | } 72 | } else { 73 | for _, m := range meta.Metafields.Nodes { 74 | toAdd = append(toAdd, &m) 75 | } 76 | } 77 | 78 | attemptSync := func(pid string) error { 79 | if _, err := h.handleProductMetaFieldsSet(pid, toAdd); err != nil { 80 | return err 81 | } 82 | if _, err := h.handleProductMetaFieldsDelete(pid, toDelete); err != nil { 83 | return err 84 | } 85 | return nil 86 | } 87 | h.Logger.V(1).Info("Attempting to sync product metafileds", "oldID", meta.ProductID, "upstreamID", realProductID) 88 | if h.DryRun { 89 | h.Logger.V(tlog.VL2).Infof("Product metafields to sync - add: %d, remove: %d", len(toAdd), len(toDelete)) 90 | h.Logger.V(tlog.VL3).Warn("Skipping product metafields sync") 91 | h.Summary.Passed += 1 92 | return nil, nil 93 | } 94 | err = attemptSync(realProductID) 95 | if err != nil { 96 | h.Logger.Error("Failed to sync product metafields", "oldID", meta.ProductID, "upstreamID", realProductID) 97 | h.Summary.Failed += 1 98 | return nil, err 99 | } 100 | h.Summary.Passed += 1 101 | return nil, nil 102 | } 103 | 104 | func (h Metafield) handleProductMetaFieldsSet(productID string, toAdd []*schema.Metafield) (*api.MetafieldSetResponse, error) { 105 | if len(toAdd) == 0 { 106 | return nil, nil 107 | } 108 | 109 | metafields := make([]schema.MetafieldsSetInput, 0, len(toAdd)) 110 | for _, m := range toAdd { 111 | metafields = append(metafields, schema.MetafieldsSetInput{ 112 | Namespace: &m.Namespace, 113 | Key: m.Key, 114 | Value: m.Value, 115 | OwnerID: productID, 116 | Type: &m.Type, 117 | }) 118 | } 119 | h.Logger.V(tlog.VL2).Info("Attempting to set product metafields", "id", productID) 120 | return h.Client.SetMetafields(metafields) 121 | } 122 | 123 | func (h Metafield) handleProductMetaFieldsDelete(productID string, toDelete []*schema.Metafield) (*api.MetafieldDeleteResponse, error) { 124 | if len(toDelete) == 0 { 125 | return nil, nil 126 | } 127 | 128 | metafields := make([]schema.MetafieldIdentifierInput, 0, len(toDelete)) 129 | for _, m := range toDelete { 130 | metafields = append(metafields, schema.MetafieldIdentifierInput{ 131 | Key: m.Key, 132 | Namespace: m.Namespace, 133 | OwnerID: productID, 134 | }) 135 | } 136 | h.Logger.V(tlog.VL2).Info("Attempting to delete product metafields", "id", productID) 137 | return h.Client.DeleteMetafields(metafields) 138 | } 139 | -------------------------------------------------------------------------------- /internal/engine/backup_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | type mockHandler struct { 14 | dataFile string 15 | } 16 | 17 | func (m *mockHandler) Handle(data any) (any, error) { 18 | content, err := os.ReadFile(m.dataFile) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | var jsonContent map[string]any 24 | err = json.Unmarshal(content, &jsonContent) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | return jsonContent, nil 30 | } 31 | 32 | func TestBackup_Do(t *testing.T) { 33 | path := "./testdata/.tmp" 34 | now := time.Now().Format("2006_01_02_15_04_05") 35 | 36 | bkpEng := NewBackup("teststore.example.com", WithBackupRoot(path+"/test")) 37 | root := fmt.Sprintf("%s/test/%s_%s", path, now, bkpEng.id) 38 | 39 | eng := New(bkpEng) 40 | eng.Register(Product) 41 | 42 | jobs := []ResourceCollection{ 43 | { 44 | Parent: func() *Resource { 45 | r := NewResource( 46 | Product, 47 | "8737843216608", 48 | &mockHandler{dataFile: "./testdata/product.json"}, 49 | ) 50 | return &r 51 | }(), 52 | Children: []Resource{ 53 | NewResource( 54 | ProductVariant, 55 | "8737843216608", 56 | &mockHandler{dataFile: "./testdata/variants.json"}, 57 | ), 58 | NewResource( 59 | ProductMedia, 60 | "8737843216608", 61 | &mockHandler{dataFile: "./testdata/media.json"}, 62 | ), 63 | }, 64 | }, 65 | { 66 | Parent: func() *Resource { 67 | r := NewResource( 68 | Product, 69 | "8737843347680", 70 | &mockHandler{dataFile: "./testdata/empty.json"}, 71 | ) 72 | return &r 73 | }(), 74 | Children: []Resource{ 75 | NewResource( 76 | ProductVariant, 77 | "8737843347680", 78 | &mockHandler{dataFile: "./testdata/empty.json"}, 79 | ), 80 | NewResource( 81 | ProductMedia, 82 | "8737843347680", 83 | &mockHandler{dataFile: "./testdata/empty.json"}, 84 | ), 85 | }, 86 | }, 87 | { 88 | Parent: func() *Resource { 89 | r := NewResource( 90 | ProductMedia, 91 | "8773308023008", 92 | &mockHandler{dataFile: "./testdata/empty.json"}, 93 | ) 94 | return &r 95 | }(), 96 | }, 97 | } 98 | 99 | go func() { 100 | defer eng.Done(Product) 101 | 102 | for _, j := range jobs { 103 | eng.Add(Product, j) 104 | } 105 | }() 106 | 107 | for res := range eng.Run(Product) { 108 | assert.NoError(t, res.Err) 109 | } 110 | 111 | // Assert that folder and files were created. 112 | assert.DirExists(t, path+"/test") 113 | assert.DirExists(t, root+"/8773308023008") 114 | 115 | assert.FileExists(t, root+"/8737843216608/product.json") 116 | assert.FileExists(t, root+"/8737843216608/product_variants.json") 117 | assert.FileExists(t, root+"/8737843216608/product_media.json") 118 | 119 | assert.FileExists(t, root+"/8737843347680/product.json") 120 | assert.FileExists(t, root+"/8737843347680/product_variants.json") 121 | assert.FileExists(t, root+"/8737843347680/product_media.json") 122 | 123 | assert.FileExists(t, root+"/8773308023008/product_media.json") 124 | 125 | // Assert file contents. 126 | content, err := os.ReadFile(root + "/8737843216608/product.json") 127 | assert.NoError(t, err) 128 | assert.Equal( 129 | t, 130 | `{"createdAt":"2024-11-03T16:36:15Z","id":"gid://shopify/Product/8737843216608","title":"Test Product","totalInventory":50}`, 131 | string(content), 132 | ) 133 | 134 | content, err = os.ReadFile(root + "/8737843216608/product_variants.json") 135 | assert.NoError(t, err) 136 | assert.Equal( 137 | t, 138 | `{"id":"gid://shopify/Product/8737843216608","variants":{"edges":[{"node":{"availableForSale":true,"createdAt":"2024-04-12T18:27:08Z","displayName":"Test Product"}}]}}`, 139 | string(content)) 140 | 141 | content, err = os.ReadFile(root + "/8737843216608/product_media.json") 142 | assert.NoError(t, err) 143 | assert.Equal( 144 | t, 145 | `{"id":"gid://shopify/Product/8737843216608","media":{"edges":[{"node":{"id":"gid://shopify/MediaImage/33201292214520","mediaContentType":"IMAGE","mediaErrors":[],"mediaWarnings":[],"preview":{"image":{"altText":"Test Media","height":1600,"metafield":null,"metafields":{"edges":null,"nodes":null,"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}},"url":"https://cdn.shopify.com/s/files/1/0695/7373/8744/files/Main_b13ad453-477c-4ed1-9b43-81f3345adfd6.jpg?v=1712946428","width":1600},"status":"READY"},"status":"READY"}}]}}`, 146 | string(content), 147 | ) 148 | 149 | content, err = os.ReadFile(root + "/8773308023008/product_media.json") 150 | assert.NoError(t, err) 151 | assert.Equal(t, "{}", string(content)) 152 | 153 | // Clean up. 154 | assert.NoError(t, os.RemoveAll(path)) 155 | } 156 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ankitpokhrel/shopctl 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/JohannesKaufmann/html-to-markdown/v2 v2.3.3 7 | github.com/charmbracelet/bubbles v0.21.0 8 | github.com/charmbracelet/bubbletea v1.3.5 9 | github.com/charmbracelet/glamour v0.10.0 10 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 11 | github.com/fatih/color v1.18.0 12 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 13 | github.com/hashicorp/go-retryablehttp v0.7.7 14 | github.com/knadh/koanf/parsers/yaml v1.0.0 15 | github.com/knadh/koanf/providers/file v1.2.0 16 | github.com/knadh/koanf/providers/structs v1.0.0 17 | github.com/knadh/koanf/v2 v2.2.0 18 | github.com/kr/text v0.2.0 19 | github.com/mattn/go-isatty v0.0.20 20 | github.com/mattn/go-runewidth v0.0.16 21 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d 22 | github.com/mholt/archives v0.1.2 23 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c 24 | github.com/spf13/cobra v1.9.1 25 | github.com/stretchr/testify v1.10.0 26 | github.com/zalando/go-keyring v0.2.6 27 | go.uber.org/zap v1.27.0 28 | go.uber.org/zap/exp v0.3.0 29 | golang.design/x/clipboard v0.7.0 30 | golang.org/x/oauth2 v0.30.0 31 | gopkg.in/yaml.v3 v3.0.1 32 | ) 33 | 34 | require ( 35 | al.essio.dev/pkg/shellescape v1.6.0 // indirect 36 | github.com/JohannesKaufmann/dom v0.2.0 // indirect 37 | github.com/STARRY-S/zip v0.2.3 // indirect 38 | github.com/alecthomas/chroma/v2 v2.18.0 // indirect 39 | github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 // indirect 40 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 41 | github.com/aymerick/douceur v0.2.0 // indirect 42 | github.com/bodgit/plumbing v1.3.0 // indirect 43 | github.com/bodgit/sevenzip v1.6.1 // indirect 44 | github.com/bodgit/windows v1.0.1 // indirect 45 | github.com/charmbracelet/colorprofile v0.3.1 // indirect 46 | github.com/charmbracelet/x/ansi v0.9.2 // indirect 47 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 48 | github.com/charmbracelet/x/exp/slice v0.0.0-20250528180458-2d5d6cb84620 // indirect 49 | github.com/charmbracelet/x/term v0.2.1 // indirect 50 | github.com/danieljoos/wincred v1.2.2 // indirect 51 | github.com/davecgh/go-spew v1.1.1 // indirect 52 | github.com/dlclark/regexp2 v1.11.5 // indirect 53 | github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect 54 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 55 | github.com/fatih/structs v1.1.0 // indirect 56 | github.com/fsnotify/fsnotify v1.9.0 // indirect 57 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 58 | github.com/godbus/dbus/v5 v5.1.0 // indirect 59 | github.com/gorilla/css v1.0.1 // indirect 60 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 61 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 62 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 63 | github.com/klauspost/compress v1.18.0 // indirect 64 | github.com/klauspost/pgzip v1.2.6 // indirect 65 | github.com/knadh/koanf/maps v0.1.2 // indirect 66 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 67 | github.com/mattn/go-colorable v0.1.14 // indirect 68 | github.com/mattn/go-localereader v0.0.1 // indirect 69 | github.com/microcosm-cc/bluemonday v1.0.27 // indirect 70 | github.com/minio/minlz v1.0.1 // indirect 71 | github.com/mitchellh/copystructure v1.2.0 // indirect 72 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 73 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 74 | github.com/muesli/cancelreader v0.2.2 // indirect 75 | github.com/muesli/reflow v0.3.0 // indirect 76 | github.com/muesli/termenv v0.16.0 // indirect 77 | github.com/nwaples/rardecode/v2 v2.1.1 // indirect 78 | github.com/pierrec/lz4/v4 v4.1.22 // indirect 79 | github.com/pmezard/go-difflib v1.0.0 // indirect 80 | github.com/rivo/uniseg v0.4.7 // indirect 81 | github.com/sorairolake/lzip-go v0.3.7 // indirect 82 | github.com/spf13/afero v1.14.0 // indirect 83 | github.com/spf13/pflag v1.0.6 // indirect 84 | github.com/therootcompany/xz v1.0.1 // indirect 85 | github.com/ulikunitz/xz v0.5.14 // indirect 86 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 87 | github.com/yuin/goldmark v1.7.12 // indirect 88 | github.com/yuin/goldmark-emoji v1.0.6 // indirect 89 | go.uber.org/multierr v1.11.0 // indirect 90 | go4.org v0.0.0-20230225012048-214862532bf5 // indirect 91 | golang.org/x/exp/shiny v0.0.0-20250506013437-ce4c2cf36ca6 // indirect 92 | golang.org/x/image v0.27.0 // indirect 93 | golang.org/x/mobile v0.0.0-20250520180527-a1d90793fc63 // indirect 94 | golang.org/x/net v0.40.0 // indirect 95 | golang.org/x/sync v0.14.0 // indirect 96 | golang.org/x/sys v0.33.0 // indirect 97 | golang.org/x/term v0.32.0 // indirect 98 | golang.org/x/text v0.25.0 // indirect 99 | ) 100 | -------------------------------------------------------------------------------- /internal/config/shop.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "github.com/knadh/koanf/providers/structs" 8 | "github.com/knadh/koanf/v2" 9 | 10 | "github.com/ankitpokhrel/shopctl" 11 | ) 12 | 13 | const ( 14 | shopConfig = ".shopconfig" 15 | ) 16 | 17 | // BackupResource wraps resource type and query filter. 18 | type BackupResource struct { 19 | Resource string `koanf:"resource" yaml:"resource"` 20 | Query string `koanf:"query" yaml:"query,omitempty"` 21 | } 22 | 23 | // StoreContext is a shopify store context. 24 | type StoreContext struct { 25 | Alias string `koanf:"alias" yaml:"alias"` 26 | Store string `koanf:"store" yaml:"store"` 27 | Token *string `koanf:"token" yaml:"token,omitempty"` 28 | } 29 | 30 | type shopItems struct { 31 | Version string `koanf:"ver" yaml:"ver"` 32 | Contexts []StoreContext `koanf:"contexts" yaml:"contexts"` 33 | CurrentCtx string `koanf:"currentContext" yaml:"currentContext"` 34 | } 35 | 36 | // ShopConfig is a Shopify store config. 37 | type ShopConfig struct { 38 | *config 39 | data shopItems 40 | } 41 | 42 | // NewShopConfig constructs a new config for a given store. 43 | func NewShopConfig() (*ShopConfig, error) { 44 | cfg, err := newConfig(home(), shopConfig, fileTypeYaml) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | // Load the existing config if it exists. 50 | var item shopItems 51 | if err := cfg.writer.Unmarshal("", &item); err != nil { 52 | return nil, err 53 | } 54 | 55 | ver := shopctl.AppConfigVersion 56 | if item.Version == "" { 57 | item.Version = ver 58 | } 59 | 60 | shopCfg := ShopConfig{ 61 | config: cfg, 62 | data: item, 63 | } 64 | return &shopCfg, nil 65 | } 66 | 67 | // HasContext checks if the given context exists. 68 | func (c *ShopConfig) HasContext(ctx string) bool { 69 | for _, x := range c.data.Contexts { 70 | if x.Alias == ctx { 71 | return true 72 | } 73 | } 74 | return false 75 | } 76 | 77 | // GetContext returns the given context if it exists. 78 | func (c *ShopConfig) GetContext(ctx string) *StoreContext { 79 | for _, x := range c.data.Contexts { 80 | if x.Alias == ctx { 81 | return &x 82 | } 83 | } 84 | return nil 85 | } 86 | 87 | // SetStoreContext adds a store context to the shop config. 88 | // It will update the context if it already exist. 89 | func (c *ShopConfig) SetStoreContext(ctx *StoreContext) { 90 | for i, x := range c.data.Contexts { 91 | if x.Store != ctx.Store { 92 | continue 93 | } 94 | c.data.Contexts[i].Alias = ctx.Alias 95 | if ctx.Token != nil { 96 | c.data.Contexts[i].Token = ctx.Token 97 | } 98 | return 99 | } 100 | c.data.Contexts = append(c.data.Contexts, *ctx) 101 | } 102 | 103 | // SetCurrentContext updates current active context. 104 | func (c *ShopConfig) SetCurrentContext(ctx string) error { 105 | if !c.HasContext(ctx) { 106 | return fmt.Errorf("no context exists with the name: %q", ctx) 107 | } 108 | c.data.CurrentCtx = ctx 109 | return nil 110 | } 111 | 112 | // CurrentContext returns current context. 113 | func (c *ShopConfig) CurrentContext() string { 114 | return c.data.CurrentCtx 115 | } 116 | 117 | // Contexts returns all available contexts. 118 | func (c *ShopConfig) Contexts() []StoreContext { 119 | return c.data.Contexts 120 | } 121 | 122 | // UnsetCurrentContext unsets current context. 123 | func (c *ShopConfig) UnsetCurrentContext() { 124 | c.data.CurrentCtx = "" 125 | } 126 | 127 | // UnsetContext unsets given context. 128 | func (c *ShopConfig) UnsetContext(ctx string) { 129 | for i, x := range c.data.Contexts { 130 | if x.Alias == ctx { 131 | c.data.Contexts = append(c.data.Contexts[:i], c.data.Contexts[i+1:]...) 132 | break 133 | } 134 | } 135 | } 136 | 137 | // RenameContext sets the new context name. 138 | func (c *ShopConfig) RenameContext(oldname string, newname string) { 139 | for i, s := range c.data.Contexts { 140 | if s.Alias == oldname { 141 | c.data.Contexts[i].Alias = newname 142 | break 143 | } 144 | } 145 | } 146 | 147 | // Save saves the config of a store to the file. 148 | func (c *ShopConfig) Save() error { 149 | k := koanf.New(".") 150 | 151 | if err := k.Load(structs.Provider(c.data, "yaml"), nil); err != nil { 152 | return err 153 | } 154 | if err := c.writer.Merge(k); err != nil { 155 | return err 156 | } 157 | return writeYAML(c.path, c.data) 158 | } 159 | 160 | // GetToken retrieves token of a store from the config. 161 | func GetToken(alias string) string { 162 | w, err := loadConfig(filepath.Join(home(), fmt.Sprintf("%s.yml", alias))) 163 | if err != nil { 164 | return "" 165 | } 166 | 167 | var item shopItems 168 | if err := w.Unmarshal("", &item); err != nil { 169 | return "" 170 | } 171 | 172 | for _, c := range item.Contexts { 173 | if c.Alias == alias { 174 | return *c.Token 175 | } 176 | } 177 | return "" 178 | } 179 | -------------------------------------------------------------------------------- /examples/pipeline/product-enrichment/scripts/enrich_products.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import json 4 | import csv 5 | import tarfile 6 | import tempfile 7 | 8 | from openai import OpenAI 9 | from time import sleep 10 | 11 | client = OpenAI() 12 | BATCH_SIZE = 15 13 | 14 | # --- Function Schema for GPT --- 15 | function_def = { 16 | "name": "enrich_products", 17 | "description": "Generate enriched product fields for a batch of Shopify products.", 18 | "parameters": { 19 | "type": "object", 20 | "properties": { 21 | "products": { 22 | "type": "array", 23 | "items": { 24 | "type": "object", 25 | "properties": { 26 | "product_id": {"type": "string"}, 27 | "new_title": {"type": "string"}, 28 | "seo_title": {"type": "string"}, 29 | "seo_description": {"type": "string"}, 30 | }, 31 | "required": ["product_id", "new_title", "seo_title", "seo_description"] 32 | } 33 | } 34 | }, 35 | "required": ["products"] 36 | } 37 | } 38 | 39 | # --- Extract files from the exported .tar.gz file --- 40 | def extract_products_from_tar(tar_path): 41 | temp_dir = tempfile.mkdtemp() 42 | with tarfile.open(tar_path, "r:gz") as tar: 43 | def safe_extract_filter(tarinfo, path): 44 | tarinfo.name = os.path.normpath(tarinfo.name).lstrip(os.sep) 45 | return tarinfo 46 | tar.extractall(path=temp_dir, filter=safe_extract_filter) 47 | 48 | product_dir = os.path.join(temp_dir, "products") 49 | if not os.path.isdir(product_dir): 50 | raise FileNotFoundError("products/ folder not found in archive") 51 | 52 | product_data = [] 53 | for folder in os.listdir(product_dir): 54 | path = os.path.join(product_dir, folder, "product.json") 55 | if os.path.isfile(path): 56 | with open(path, "r", encoding="utf-8") as f: 57 | try: 58 | product = json.load(f) 59 | product_data.append(product) 60 | except json.JSONDecodeError: 61 | print(f"⚠️ Skipping {folder}: invalid JSON") 62 | return product_data 63 | 64 | # --- Prompt builder (user content) --- 65 | def build_batch_prompt(batch): 66 | return f""" 67 | Enrich each product below by generating: 68 | - new_title: improved title (if needed) 69 | - seo_title: keyword-rich, short title for SEO 70 | - seo_description: 1–2 sentence summary for search engines 71 | 72 | Use the 'enrich_products' function to return a JSON array of enriched results. 73 | 74 | Input: 75 | {json.dumps([ 76 | { 77 | "product_id": str(p["id"]), 78 | "title": p.get("title", ""), 79 | "description": p.get("body_html", ""), 80 | "tags": p.get("tags", []), 81 | } for p in batch 82 | ], indent=2)} 83 | """ 84 | 85 | # --- GPT-4 Function Call API --- 86 | def enrich_batch(batch): 87 | prompt = build_batch_prompt(batch) 88 | 89 | try: 90 | response = client.chat.completions.create( 91 | model="gpt-4-0613", 92 | messages=[ 93 | {"role": "user", "content": prompt} 94 | ], 95 | tools=[{ 96 | "type": "function", 97 | "function": function_def 98 | }], 99 | tool_choice={ 100 | "type": "function", 101 | "function": {"name": "enrich_products"} 102 | }, 103 | ) 104 | 105 | args = json.loads(response.choices[0].message.tool_calls[0].function.arguments) 106 | return args["products"] 107 | 108 | except Exception as e: 109 | print(f"[ERROR] Failed to enrich batch: {e}") 110 | sleep(2) 111 | return [] 112 | 113 | # --- Main --- 114 | def main(tar_path, output_csv): 115 | print("📦 Reading archive:", tar_path) 116 | products = extract_products_from_tar(tar_path) 117 | 118 | enriched_rows = [] 119 | for i in range(0, len(products), BATCH_SIZE): 120 | batch = products[i:i + BATCH_SIZE] 121 | print(f"✨ Enriching products {i+1} to {i+len(batch)}...") 122 | enriched_rows.extend(enrich_batch(batch)) 123 | 124 | print(f"💾 Writing enriched data to: {output_csv}") 125 | with open(output_csv, "w", newline="", encoding="utf-8") as f: 126 | writer = csv.DictWriter(f, fieldnames=[ 127 | "product_id", "new_title", "seo_title", "seo_description" 128 | ]) 129 | writer.writeheader() 130 | writer.writerows(enriched_rows) 131 | 132 | print("✅ Done. Enriched", len(enriched_rows), "products.") 133 | 134 | if __name__ == "__main__": 135 | if len(sys.argv) != 3: 136 | print("Usage: enrich_products.py ") 137 | sys.exit(1) 138 | main(sys.argv[1], sys.argv[2]) 139 | -------------------------------------------------------------------------------- /examples/pipeline/product-enrichment/scripts/review_catalog.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import json 4 | import tarfile 5 | import tempfile 6 | from openai import OpenAI 7 | 8 | client = OpenAI() 9 | 10 | # --- Extract lightweight product info for review from the exported .tar.gz file --- 11 | def extract_reviewable_products(tar_path, max_items=30): 12 | temp_dir = tempfile.mkdtemp() 13 | with tarfile.open(tar_path, "r:gz") as tar: 14 | def safe_extract_filter(tarinfo, path): 15 | tarinfo.name = os.path.normpath(tarinfo.name).lstrip(os.sep) 16 | return tarinfo 17 | tar.extractall(path=temp_dir, filter=safe_extract_filter) 18 | 19 | product_dir = os.path.join(temp_dir, "products") 20 | products = [] 21 | 22 | for folder in os.listdir(product_dir): 23 | product_path = os.path.join(product_dir, folder, "product.json") 24 | variant_path = os.path.join(product_dir, folder, "product_variants.json") 25 | 26 | if os.path.isfile(product_path): 27 | with open(product_path, "r", encoding="utf-8") as f: 28 | try: 29 | product = json.load(f) 30 | except json.JSONDecodeError: 31 | continue 32 | 33 | product_entry = { 34 | "id": str(product.get("id")), 35 | "productType": product.get("productType", ""), 36 | "tags": product.get("tags", []), 37 | "status": product.get("status", ""), 38 | "publishedScope": product.get("publishedScope", ""), 39 | "vendor": product.get("vendor", ""), 40 | "mediaCount": product.get("mediaCount", 0), 41 | "variantsCount": product.get("variantsCount", 0), 42 | "variants": [] 43 | } 44 | 45 | if os.path.isfile(variant_path): 46 | with open(variant_path, "r", encoding="utf-8") as vf: 47 | variant_data = json.load(vf) 48 | variants = variant_data.get("variants", {}).get("nodes", []) 49 | 50 | for v in variants: 51 | print(v) 52 | variant = { 53 | "title": v.get("title", ""), 54 | "sku": v.get("sku", ""), 55 | "price": v.get("price", ""), 56 | "inventoryQuantity": v.get("inventoryQuantity", 0), 57 | "inventoryPolicy": v.get("inventoryPolicy", ""), 58 | "requiresShipping": v.get("requiresShipping", True), 59 | "taxable": v.get("taxable", True), 60 | "optionValues": v.get("optionValues", []), 61 | "availableForSale": v.get("availableForSale", False), 62 | "sellableOnlineQuantity": v.get("sellableOnlineQuantity", 0) 63 | } 64 | product_entry["variants"].append(variant) 65 | 66 | products.append(product_entry) 67 | 68 | if len(products) >= max_items: 69 | break 70 | 71 | return products 72 | 73 | # --- Send data to GPT for catalog insights --- 74 | def generate_catalog_review(products): 75 | prompt = f""" 76 | You are an expert eCommerce catalog auditor. The product titles and descriptions have already been optimized, so exclude those. 77 | 78 | Please review the following product catalog sample and identify: 79 | 1. Issues or inconsistencies in tags, product types, or variants 80 | 2. Missing or inconsistent inventory information 81 | 3. Gaps in product configuration or variant structure 82 | 4. Duplicate or overly similar products 83 | 5. General recommendations to improve catalog quality and completeness 84 | 85 | Respond in clear, concise Markdown. 86 | 87 | Sample products: 88 | {json.dumps(products, indent=2)} 89 | """ 90 | 91 | response = client.chat.completions.create( 92 | model="gpt-4", 93 | messages=[{"role": "user", "content": prompt}], 94 | temperature=0.7 95 | ) 96 | 97 | return response.choices[0].message.content.strip() 98 | 99 | # --- Main entry --- 100 | def main(tar_path, output_md): 101 | print("📦 Extracting reviewable product data...") 102 | products = extract_reviewable_products(tar_path) 103 | 104 | print(f"🧠 Generating catalog review for {len(products)} products...") 105 | summary = generate_catalog_review(products) 106 | 107 | with open(output_md, "w", encoding="utf-8") as f: 108 | f.write(summary) 109 | 110 | print("✅ Catalog review saved to:", output_md) 111 | 112 | if __name__ == "__main__": 113 | if len(sys.argv) != 3: 114 | print("Usage: review_products.py ") 115 | sys.exit(1) 116 | main(sys.argv[1], sys.argv[2]) 117 | -------------------------------------------------------------------------------- /internal/runner/restore/customer/handler/metafields.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/ankitpokhrel/shopctl" 8 | "github.com/ankitpokhrel/shopctl/internal/api" 9 | "github.com/ankitpokhrel/shopctl/internal/registry" 10 | "github.com/ankitpokhrel/shopctl/internal/runner" 11 | "github.com/ankitpokhrel/shopctl/pkg/tlog" 12 | "github.com/ankitpokhrel/shopctl/schema" 13 | ) 14 | 15 | type Metafield struct { 16 | Client *api.GQLClient 17 | Logger *tlog.Logger 18 | File registry.File 19 | Summary *runner.Summary 20 | DryRun bool 21 | } 22 | 23 | func (h Metafield) Handle(data any) (any, error) { 24 | var realCustomerID string 25 | if id, ok := data.(string); ok { 26 | realCustomerID = id 27 | } else { 28 | return nil, fmt.Errorf("unable to figure out real cusotmer ID") 29 | } 30 | 31 | metaRaw, err := registry.ReadFileContents(h.File.Path) 32 | if err != nil { 33 | h.Logger.Error("Unable to read contents", "file", h.File.Path, "error", err) 34 | return nil, err 35 | } 36 | h.Summary.Count += 1 37 | 38 | var meta api.CustomerMetafieldsData 39 | if err = json.Unmarshal(metaRaw, &meta); err != nil { 40 | h.Logger.Error("Unable to marshal contents", "file", h.File.Path, "error", err) 41 | h.Summary.Failed += 1 42 | return nil, err 43 | } 44 | if len(meta.Metafields.Nodes) == 0 { 45 | return nil, nil 46 | } 47 | 48 | keyme := func(namespace, key string) string { 49 | return fmt.Sprintf("%s.%s", namespace, key) 50 | } 51 | 52 | toAdd := make([]*schema.Metafield, 0) 53 | toDelete := make([]*schema.Metafield, 0) 54 | 55 | // Get upstream metafields. 56 | currentMetafields, _ := h.Client.GetCustomerMetaFieldsByEmailOrPhoneOrID( 57 | &meta.Email, &meta.Phone, 58 | shopctl.ExtractNumericID(realCustomerID), 59 | ) 60 | if currentMetafields != nil { 61 | currentMetaNode := currentMetafields.Data.Customers.Nodes[0] 62 | 63 | currentMetafieldsMap := make(map[string]*schema.Metafield, len(currentMetaNode.Metafields.Nodes)) 64 | for _, m := range currentMetaNode.Metafields.Nodes { 65 | key := keyme(m.Namespace, m.Key) 66 | currentMetafieldsMap[key] = &m 67 | } 68 | 69 | backupMetafieldsMap := make(map[string]*schema.Metafield, len(meta.Metafields.Nodes)) 70 | for _, m := range meta.Metafields.Nodes { 71 | key := keyme(m.Namespace, m.Key) 72 | backupMetafieldsMap[key] = &m 73 | } 74 | 75 | for id, cm := range currentMetafieldsMap { 76 | if m, ok := backupMetafieldsMap[id]; ok { 77 | toAdd = append(toAdd, m) 78 | } else { 79 | toDelete = append(toDelete, cm) 80 | } 81 | } 82 | for id, m := range backupMetafieldsMap { 83 | if _, ok := currentMetafieldsMap[id]; !ok { 84 | toAdd = append(toAdd, m) 85 | } 86 | } 87 | } else { 88 | for _, m := range meta.Metafields.Nodes { 89 | toAdd = append(toAdd, &m) 90 | } 91 | } 92 | 93 | attemptSync := func(cid string) error { 94 | if _, err := h.handleCustomerMetaFieldsSet(cid, toAdd); err != nil { 95 | return err 96 | } 97 | if _, err := h.handleCustomerMetaFieldsDelete(cid, toDelete); err != nil { 98 | return err 99 | } 100 | return nil 101 | } 102 | if h.DryRun { 103 | h.Logger.V(tlog.VL2).Infof("Customer metafields to sync - add: %d, remove: %d", len(toAdd), len(toDelete)) 104 | h.Logger.V(tlog.VL3).Warn("Skipping customer metafields sync") 105 | h.Summary.Passed += 1 106 | return nil, nil 107 | } 108 | err = attemptSync(realCustomerID) 109 | if err != nil { 110 | h.Logger.Error("Failed to sync customer metafields", "oldID", meta.CustomerID, "upstreamID", realCustomerID) 111 | h.Summary.Failed += 1 112 | return nil, err 113 | } 114 | h.Summary.Passed += 1 115 | return nil, err 116 | } 117 | 118 | func (h Metafield) handleCustomerMetaFieldsSet(customerID string, toAdd []*schema.Metafield) (*api.MetafieldSetResponse, error) { 119 | if len(toAdd) == 0 { 120 | return nil, nil 121 | } 122 | 123 | metafields := make([]schema.MetafieldsSetInput, 0, len(toAdd)) 124 | for _, m := range toAdd { 125 | metafields = append(metafields, schema.MetafieldsSetInput{ 126 | Namespace: &m.Namespace, 127 | Key: m.Key, 128 | Value: m.Value, 129 | OwnerID: customerID, 130 | Type: &m.Type, 131 | }) 132 | } 133 | h.Logger.V(tlog.VL2).Info("Attempting to set customer metafields", "id", customerID) 134 | return h.Client.SetMetafields(metafields) 135 | } 136 | 137 | func (h Metafield) handleCustomerMetaFieldsDelete(customerID string, toDelete []*schema.Metafield) (*api.MetafieldDeleteResponse, error) { 138 | if len(toDelete) == 0 { 139 | return nil, nil 140 | } 141 | 142 | metafields := make([]schema.MetafieldIdentifierInput, 0, len(toDelete)) 143 | for _, m := range toDelete { 144 | metafields = append(metafields, schema.MetafieldIdentifierInput{ 145 | Key: m.Key, 146 | Namespace: m.Namespace, 147 | OwnerID: customerID, 148 | }) 149 | } 150 | h.Logger.V(tlog.VL2).Info("Attempting to delete customer metafields", "id", customerID) 151 | return h.Client.DeleteMetafields(metafields) 152 | } 153 | -------------------------------------------------------------------------------- /internal/runner/restore/customer/customer.go: -------------------------------------------------------------------------------- 1 | package customer 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | "time" 9 | 10 | "github.com/ankitpokhrel/shopctl/internal/api" 11 | "github.com/ankitpokhrel/shopctl/internal/engine" 12 | "github.com/ankitpokhrel/shopctl/internal/registry" 13 | "github.com/ankitpokhrel/shopctl/internal/runner" 14 | "github.com/ankitpokhrel/shopctl/internal/runner/restore/customer/handler" 15 | "github.com/ankitpokhrel/shopctl/pkg/tlog" 16 | ) 17 | 18 | // Runner is a product restore runner. 19 | type Runner struct { 20 | path string 21 | eng *engine.Engine 22 | rstEng *engine.Restore 23 | client *api.GQLClient 24 | logger *tlog.Logger 25 | stats map[engine.ResourceType]*runner.Summary 26 | filters *runner.RestoreFilter 27 | isDryRun bool 28 | } 29 | 30 | // NewRunner constructs a new restore runner. 31 | func NewRunner(path string, eng *engine.Engine, client *api.GQLClient, logger *tlog.Logger, filters *runner.RestoreFilter, isDryRun bool) *Runner { 32 | rstEng := eng.Doer().(*engine.Restore) 33 | 34 | stats := make(map[engine.ResourceType]*runner.Summary) 35 | for _, rt := range engine.GetCustomerResourceTypes() { 36 | stats[rt] = &runner.Summary{} 37 | } 38 | 39 | return &Runner{ 40 | path: path, 41 | eng: eng, 42 | rstEng: rstEng, 43 | client: client, 44 | logger: logger, 45 | stats: stats, 46 | filters: filters, 47 | isDryRun: isDryRun, 48 | } 49 | } 50 | 51 | // Kind returns runner type; implements `runner.Runner` interface. 52 | func (r *Runner) Kind() engine.ResourceType { 53 | return engine.Customer 54 | } 55 | 56 | // Run executes customer restoration process; implements `runner.Runner` interface. 57 | func (r *Runner) Run() error { 58 | r.eng.Register(engine.Customer) 59 | restoreStart := time.Now() 60 | 61 | go func() { 62 | defer r.eng.Done(engine.Customer) 63 | 64 | // TODO: Handle/log error. 65 | _ = r.restore() 66 | }() 67 | 68 | for res := range r.eng.Run(engine.Customer) { 69 | if res.Err != nil && !errors.Is(res.Err, engine.ErrSkipChildren) { 70 | r.stats[res.ResourceType].Failed += 1 71 | r.logger.Errorf("Failed to restore resource %s: %v\n", res.ResourceType, res.Err) 72 | } 73 | } 74 | 75 | r.logger.V(tlog.VL3).Infof( 76 | "Customer restore complete in %s", 77 | time.Since(restoreStart), 78 | ) 79 | return nil 80 | } 81 | 82 | func (r *Runner) restore() error { 83 | foundFiles, err := registry.GetAllInDir(r.path, ".json") 84 | if err != nil { 85 | return err 86 | } 87 | 88 | // This is the max number of resources we're expecting to process. 89 | const maxNumResources = 2 90 | 91 | // When adding resource to the resource collection we need to maintain 92 | // following order: Customer -> Metafields 93 | const ( 94 | Customer = iota 95 | Metafields 96 | ) 97 | 98 | // Initialize resources with fixed slots for ordering. 99 | resources := make(map[string][][]engine.Resource) 100 | 101 | for f := range foundFiles { 102 | if f.Err != nil { 103 | r.logger.Warn("Skipping file due to read err", "file", f.Path, "error", f.Err) 104 | continue 105 | } 106 | 107 | currentID, err := extractID(f.Path) 108 | if err != nil { 109 | return err 110 | } 111 | 112 | if _, exists := resources[currentID]; !exists { 113 | resources[currentID] = make([][]engine.Resource, maxNumResources) 114 | } 115 | 116 | switch filepath.Base(f.Path) { 117 | case "customer.json": 118 | customerFn := &handler.Customer{Client: r.client, File: f, Filter: r.filters, Logger: r.logger, Summary: r.stats[engine.Customer], DryRun: r.isDryRun} 119 | resources[currentID][Customer] = append( 120 | resources[currentID][Customer], 121 | engine.NewResource(engine.Customer, r.path, customerFn), 122 | ) 123 | case "customer_metafields.json": 124 | metafieldFn := &handler.Metafield{Client: r.client, File: f, Logger: r.logger, Summary: r.stats[engine.CustomerMetaField], DryRun: r.isDryRun} 125 | resources[currentID][Metafields] = append( 126 | resources[currentID][Metafields], 127 | engine.NewResource(engine.CustomerMetaField, r.path, metafieldFn), 128 | ) 129 | 130 | } 131 | } 132 | 133 | // Flatten resources for each currentID in the defined order. 134 | for _, orderedResources := range resources { 135 | var flattened engine.ResourceCollection 136 | 137 | if len(orderedResources[Customer]) > 0 { 138 | flattened.Parent = &orderedResources[Customer][0] 139 | } 140 | 141 | for idx, rc := range orderedResources { 142 | if idx == Customer { 143 | continue 144 | } 145 | flattened.Children = append(flattened.Children, rc...) 146 | } 147 | if flattened.Parent != nil { 148 | r.eng.Add(engine.Customer, flattened) 149 | } 150 | } 151 | return nil 152 | } 153 | 154 | // Stats returns runner stats. 155 | func (r *Runner) Stats() map[engine.ResourceType]*runner.Summary { 156 | return r.stats 157 | } 158 | 159 | func extractID(path string) (string, error) { 160 | parts := strings.Split(filepath.Clean(path), string(filepath.Separator)) 161 | 162 | if len(parts) < 2 { 163 | return "", fmt.Errorf("path does not have enough elements") 164 | } 165 | return parts[len(parts)-2], nil 166 | } 167 | -------------------------------------------------------------------------------- /internal/cmd/product/create/create.go: -------------------------------------------------------------------------------- 1 | package create 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/ankitpokhrel/shopctl" 10 | "github.com/ankitpokhrel/shopctl/internal/api" 11 | "github.com/ankitpokhrel/shopctl/internal/cmdutil" 12 | "github.com/ankitpokhrel/shopctl/internal/config" 13 | "github.com/ankitpokhrel/shopctl/pkg/browser" 14 | "github.com/ankitpokhrel/shopctl/schema" 15 | ) 16 | 17 | const ( 18 | helpText = `Create lets you create a product.` 19 | 20 | examples = `$ shopctl product create --title "Product title" 21 | 22 | # Create active product in the current context 23 | $ shopctl product create -tTitle -d"Product description" --status active 24 | 25 | # Create product with tags in the current context 26 | $ shopctl product create -tTitle -d"Product description" --tags tag1,tag2 27 | 28 | # Create product in another store 29 | $ shopctl product create -c store2 -tTitle -d"Product description" --type Bags` 30 | ) 31 | 32 | type flag struct { 33 | handle string 34 | title string 35 | descHtml string 36 | typ string 37 | category string 38 | tags []string 39 | vendor string 40 | status string 41 | isGiftCard bool 42 | web bool 43 | } 44 | 45 | func (f *flag) parse(cmd *cobra.Command, _ []string) { 46 | handle, err := cmd.Flags().GetString("handle") 47 | cmdutil.ExitOnErr(err) 48 | 49 | title, err := cmd.Flags().GetString("title") 50 | cmdutil.ExitOnErr(err) 51 | 52 | if title == "" { 53 | cmdutil.ExitOnErr(cmdutil.HelpErrorf("Product title cannot be blank", examples)) 54 | } 55 | 56 | desc, err := cmd.Flags().GetString("desc") 57 | cmdutil.ExitOnErr(err) 58 | 59 | typ, err := cmd.Flags().GetString("type") 60 | cmdutil.ExitOnErr(err) 61 | 62 | category, err := cmd.Flags().GetString("category") 63 | cmdutil.ExitOnErr(err) 64 | 65 | tags, err := cmd.Flags().GetString("tags") 66 | cmdutil.ExitOnErr(err) 67 | 68 | vendor, err := cmd.Flags().GetString("vendor") 69 | cmdutil.ExitOnErr(err) 70 | 71 | status, err := cmd.Flags().GetString("status") 72 | cmdutil.ExitOnErr(err) 73 | 74 | isGiftCard, err := cmd.Flags().GetBool("gift-card") 75 | cmdutil.ExitOnErr(err) 76 | 77 | web, err := cmd.Flags().GetBool("web") 78 | cmdutil.ExitOnErr(err) 79 | 80 | f.handle = handle 81 | f.title = title 82 | f.descHtml = desc 83 | f.typ = typ 84 | f.category = category 85 | f.tags = strings.Split(tags, ",") 86 | f.vendor = vendor 87 | f.status = status 88 | f.isGiftCard = isGiftCard 89 | f.web = web 90 | } 91 | 92 | // NewCmdCreate constructs a new product create command. 93 | func NewCmdCreate() *cobra.Command { 94 | cmd := cobra.Command{ 95 | Use: "create", 96 | Short: "Create a product", 97 | Long: helpText, 98 | Example: examples, 99 | Aliases: []string{"add"}, 100 | RunE: func(cmd *cobra.Command, args []string) error { 101 | ctx := cmd.Context().Value(cmdutil.KeyContext).(*config.StoreContext) 102 | client := cmd.Context().Value(cmdutil.KeyGQLClient).(*api.GQLClient) 103 | 104 | cmdutil.ExitOnErr(run(cmd, args, ctx, client)) 105 | return nil 106 | }, 107 | } 108 | cmd.Flags().String("handle", "", "Product handle") 109 | cmd.Flags().StringP("title", "t", "", "Product title") 110 | cmd.Flags().StringP("desc", "d", "", "Product description") 111 | cmd.Flags().String("type", "", "Product type") 112 | cmd.Flags().StringP("category", "y", "", "Product category id") 113 | cmd.Flags().String("tags", "", "Comma separated list of product tags") 114 | cmd.Flags().String("vendor", "", "Product vendor") 115 | cmd.Flags().String("status", string(schema.ProductStatusDraft), "Product status (ACTIVE, ARCHIVED, DRAFT)") 116 | cmd.Flags().Bool("gift-card", false, "Is gift card?") 117 | cmd.Flags().Bool("web", false, "Open in web browser after successful creation") 118 | 119 | return &cmd 120 | } 121 | 122 | func run(cmd *cobra.Command, args []string, ctx *config.StoreContext, client *api.GQLClient) error { 123 | flag := &flag{} 124 | flag.parse(cmd, args) 125 | 126 | tags := make([]any, len(flag.tags)) 127 | for _, t := range flag.tags { 128 | tags = append(tags, t) 129 | } 130 | status := schema.ProductStatusDraft 131 | if flag.status != "" { 132 | status = schema.ProductStatus(strings.ToTitle(flag.status)) 133 | } 134 | var category *string 135 | if flag.category != "" { 136 | category = &flag.category 137 | } 138 | 139 | input := schema.ProductInput{ 140 | Handle: &flag.handle, 141 | Title: &flag.title, 142 | DescriptionHtml: &flag.descHtml, 143 | ProductType: &flag.typ, 144 | Category: category, 145 | Tags: tags, 146 | Vendor: &flag.vendor, 147 | Status: &status, 148 | GiftCard: &flag.isGiftCard, 149 | } 150 | 151 | res, err := client.CreateProduct(input) 152 | if err != nil { 153 | return err 154 | } 155 | 156 | adminURL := fmt.Sprintf( 157 | "https://admin.shopify.com/store/%s/products/%s", 158 | ctx.Alias, shopctl.ExtractNumericID(res.Product.ID), 159 | ) 160 | if flag.web { 161 | _ = browser.Browse(adminURL) 162 | } 163 | 164 | cmdutil.Success("Product created successfully: %s", res.Product.Handle) 165 | fmt.Println(adminURL) 166 | 167 | return nil 168 | } 169 | -------------------------------------------------------------------------------- /pkg/tui/table/interactive.go: -------------------------------------------------------------------------------- 1 | //nolint:mnd 2 | package table 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/charmbracelet/bubbles/viewport" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | ) 13 | 14 | // InteractiveTable wraps bubble table and viewport. 15 | type InteractiveTable struct { 16 | table Model 17 | viewport viewport.Model 18 | isAltScreen bool 19 | footerTexts []string 20 | helpTexts []string 21 | enterFunc func(string) error 22 | copyFunc func(string, string) error 23 | showHelp bool 24 | } 25 | 26 | // InteractiveTableOption is a functional opt for InteractiveTable. 27 | type InteractiveTableOption func(*InteractiveTable) 28 | 29 | // NewInteractiveTable builds a new table. 30 | func NewInteractiveTable(cols []Column, rows []Row, opts ...InteractiveTableOption) *InteractiveTable { 31 | height := min(len(rows)+1, 25) 32 | t := New( 33 | WithColumns(cols), 34 | WithRows(rows), 35 | WithFocused(true), 36 | WithHeight(height), 37 | ) 38 | 39 | vp := viewport.New(min(200, len(cols)*20), height+1) 40 | vp.SetContent(t.View()) 41 | 42 | it := InteractiveTable{ 43 | table: t, 44 | viewport: vp, 45 | } 46 | for _, o := range opts { 47 | o(&it) 48 | } 49 | return &it 50 | } 51 | 52 | // WithHelpTexts sets help text. 53 | func WithHelpTexts(txt []string) InteractiveTableOption { 54 | return func(t *InteractiveTable) { 55 | t.helpTexts = txt 56 | } 57 | } 58 | 59 | // WithFooterTexts sets footer text. 60 | func WithFooterTexts(txt []string) InteractiveTableOption { 61 | return func(t *InteractiveTable) { 62 | t.footerTexts = txt 63 | } 64 | } 65 | 66 | // WithEnterFunc registers a method to call when user presses an 'enter' key. 67 | func WithEnterFunc(fn func(id string) error) InteractiveTableOption { 68 | return func(t *InteractiveTable) { 69 | t.enterFunc = fn 70 | } 71 | } 72 | 73 | // WithCopyFunc registers a method to call when user presses 'c'. 74 | func WithCopyFunc(fn func(id string, key string) error) InteractiveTableOption { 75 | return func(t *InteractiveTable) { 76 | t.copyFunc = fn 77 | } 78 | } 79 | 80 | // Init is required for initialization. 81 | func (t *InteractiveTable) Init() tea.Cmd { return nil } 82 | 83 | // Update is the Bubble Tea update loop. 84 | func (t *InteractiveTable) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 85 | var cmds []tea.Cmd 86 | switch msg := msg.(type) { //nolint:gocritic 87 | case tea.KeyMsg: 88 | switch msg.String() { 89 | case "?": 90 | t.showHelp = !t.showHelp 91 | case "m": 92 | if !t.isAltScreen { 93 | cmds = append(cmds, tea.EnterAltScreen) 94 | } else { 95 | cmds = append(cmds, tea.ExitAltScreen) 96 | } 97 | t.isAltScreen = !t.isAltScreen 98 | case "c", "C": 99 | cmd := func() tea.Msg { 100 | id := t.table.SelectedRow()[0] 101 | return t.copyFunc(id, msg.String()) 102 | } 103 | return t, tea.Batch(cmd) 104 | case "esc": 105 | if t.isAltScreen { 106 | cmds = append(cmds, tea.ExitAltScreen) 107 | t.isAltScreen = false 108 | } else { 109 | return t, tea.Quit 110 | } 111 | case "q", "ctrl+c": 112 | return t, tea.Quit 113 | case "h", "left": 114 | t.table.GetViewport().ScrollLeft(5) 115 | t.viewport.ScrollLeft(5) 116 | case "l", "right": 117 | t.table.GetViewport().ScrollRight(5) 118 | t.viewport.ScrollRight(5) 119 | case "enter": 120 | if t.enterFunc == nil { 121 | return t, nil 122 | } 123 | cmd := func() tea.Msg { 124 | id := t.table.SelectedRow()[0] 125 | return t.enterFunc(id) 126 | } 127 | return t, tea.Batch(cmd) 128 | } 129 | } 130 | 131 | updatedTable, cmd := t.table.Update(msg) 132 | t.table = updatedTable 133 | cmds = append(cmds, cmd) 134 | 135 | t.viewport.SetContent(t.table.View()) 136 | updatedViewport, vCmd := t.viewport.Update(msg) 137 | t.viewport = updatedViewport 138 | cmds = append(cmds, vCmd) 139 | 140 | return t, tea.Batch(cmds...) 141 | } 142 | 143 | // View renders the viewport into a string. 144 | func (t *InteractiveTable) View() string { 145 | baseStyle := lipgloss.NewStyle(). 146 | BorderStyle(lipgloss.NormalBorder()). 147 | BorderForeground(lipgloss.Color("240")) 148 | footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Width(t.viewport.Width) 149 | separator := " » " 150 | 151 | footer := "" 152 | if t.showHelp { 153 | footer = footerStyle.Render( 154 | strings.Join(t.helpTexts, separator), 155 | ) 156 | } else { 157 | footer = footerStyle.Render( 158 | strings.Join(t.footerTexts, separator), 159 | ) 160 | } 161 | return baseStyle.Render(t.viewport.View()) + "\n" + footer 162 | } 163 | 164 | // Render renders the final table. 165 | func (t *InteractiveTable) Render() error { 166 | s := DefaultStyles() 167 | s.Header = s.Header. 168 | BorderStyle(lipgloss.MarkdownBorder()). 169 | BorderForeground(lipgloss.Color("240")). 170 | BorderBottom(true). 171 | Bold(false) 172 | s.Selected = s.Selected. 173 | Foreground(lipgloss.Color("229")). 174 | Background(lipgloss.Color("57")). 175 | Bold(false) 176 | t.table.SetStyles(s) 177 | 178 | if _, err := tea.NewProgram(t).Run(); err != nil { 179 | fmt.Println("Error running program:", err) 180 | os.Exit(1) 181 | } 182 | return nil 183 | } 184 | --------------------------------------------------------------------------------