├── .gitignore ├── .goreleaser.yml ├── Changes ├── README.md ├── cmd ├── admin │ ├── admin.go │ └── url.go ├── charges │ └── charges.go ├── cmd.go ├── gql │ └── gql.go ├── metafields │ ├── metafields.go │ └── sorting.go ├── orders │ └── orders.go ├── products │ └── products.go ├── scripttags │ └── scripttags.go ├── shop │ └── shop.go ├── themes │ └── themes.go └── webhooks │ └── webhooks.go ├── go.mod ├── go.sum ├── gql ├── client.go └── storefront │ └── storefront.go └── sdt.go /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/go 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=go 4 | 5 | ### Go ### 6 | # Binaries for programs and plugins 7 | *.exe 8 | *.exe~ 9 | *.dll 10 | *.so 11 | *.dylib 12 | 13 | # Test binary, built with `go test -c` 14 | *.test 15 | 16 | # Output of the go coverage tool, specifically when used with LiteIDE 17 | *.out 18 | 19 | # Dependency directories (remove the comment below to include it) 20 | # vendor/ 21 | 22 | ### Go Patch ### 23 | /vendor/ 24 | /Godeps/ 25 | 26 | # End of https://www.toptal.com/developers/gitignore/api/go 27 | 28 | /dist -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | builds: 3 | - binary: sdt 4 | env: 5 | - CGO_ENABLED=0 6 | goos: 7 | - linux 8 | - windows 9 | - darwin 10 | 11 | snapshot: 12 | name_template: "{{ .Tag }}-next" 13 | 14 | changelog: 15 | disable: true 16 | -------------------------------------------------------------------------------- /Changes: -------------------------------------------------------------------------------- 1 | v0.0.5 2024-10-09 2 | -------------------- 3 | - Add orders ls command to list orders 4 | - Add charges command to list and create charges 5 | - Add command to enable Storefront API metafields 6 | - Add more content types for theme uploads 7 | - Add SHOPIFY_PRODUCT_FIELDS env variable for setting product fields 8 | - Fix metafields type display 9 | 10 | v0.0.4 2023-05-16 11 | -------------------- 12 | - Add theme command to copy files to a theme 13 | - Add graphql command to execute a GraphQL Admin API query 14 | - Add products command to output a shop's prodcuts 15 | - Update go-shopify 16 | - Always use latest stable version of GraphQL Admin API 17 | - Support access token lookup command in other commands 18 | 19 | v0.0.3 2022-04-01 20 | -------------------- 21 | - Add support for deleting script tags 22 | 23 | v0.0.2 2022-01-05 24 | -------------------- 25 | - Add support for listing a customer's metafields 26 | - Add support for setting --access-token via SHOPIFY_API_TOKEN 27 | - Fix bug preventing metafields from being output as JSONL 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shopify Development Tools 2 | 3 | Command-line program to assist with the development and/or maintenance of Shopify apps and stores. 4 | 5 | ## Installation 6 | 7 | Download the version for your platform on the [releases page](https://github.com/ScreenStaring/shopify_dev_tools/releases). 8 | Windows, macOS/OS X, and GNU/Linux are supported. 9 | 10 | ## Usage 11 | 12 | NAME: 13 | sdt - Shopify Development Tools 14 | 15 | USAGE: 16 | sdt command [command options] [arguments...] 17 | 18 | VERSION: 19 | 0.0.5 20 | 21 | COMMANDS: 22 | admin, a Open admin pages 23 | charges, c, ch Do things with charges 24 | metafield, m, meta Metafield utilities 25 | orders, o Information about orders 26 | products, p Do things with products 27 | graphql, gql Run a GraphQL query against the Admin API 28 | shop, s Information about the given shop 29 | scripttags ScriptTag utilities 30 | themes, theme, t Theme utilities 31 | webhook, webhooks, hooks, w Webhook utilities 32 | help, h Shows a list of commands or help for one command 33 | 34 | GLOBAL OPTIONS: 35 | --help, -h show help (default: false) 36 | --version, -v print the version (default: false) 37 | 38 | ### Credentials 39 | 40 | You'll need access to the Shopify store you want to execute commands against. Also see [Environment Variables](#environment-variables). 41 | 42 | #### Access Token 43 | 44 | If the store has your app installed you can use the credentials generated when the shop installed your app: 45 | ``` 46 | sdt COMMAND --shop shopname --access-token value 47 | ``` 48 | 49 | In this scenario you will likely need to execute the command against many shops, and having to lookup the token every 50 | time you need it can become annoying. To simplify this process you can [specify an Access Token Command](#access-token-command). 51 | 52 | #### Key & Password 53 | 54 | If you have access to the store via the Shopify Admin you can authenticate by 55 | [generating private app API credentials](https://shopify.dev/tutorials/generate-api-credentials). Once obtained they can be specified as follows: 56 | ``` 57 | sdt COMMAND --shop shopname --api-key thekey --api-password thepassword 58 | ``` 59 | 60 | #### Access Token Command 61 | 62 | Instead of specifying an access token per store you can provide a custom command that can lookup the token for the given `shop`. 63 | For example: 64 | 65 | ``` 66 | sdt COMMAND --shop shopname --access-token ' ARGV[0]).token" "$shop"' 80 | ``` 81 | 82 | Furthermore, you can use the [`SHOPIFY_ACCESS_TOKEN` environment variable](#environment-variables) to reduce the required options to 83 | just `shop`: 84 | 85 | ``` 86 | export SHOPIFY_ACCESS_TOKEN=' 0 { 39 | qs := url.Values{} 40 | 41 | for k, v := range(q) { 42 | qs.Set(k, v) 43 | } 44 | 45 | s += "?"+qs.Encode() 46 | } 47 | 48 | return s 49 | } 50 | 51 | func (a *Admin) Order(id int64, q map[string]string) string { 52 | return a.buildURL(fmt.Sprintf(order, id), q) 53 | } 54 | 55 | func (a *Admin) Orders(q map[string]string) string { 56 | return a.buildURL(orders, q) 57 | } 58 | 59 | func (a *Admin) Product(id int64, q map[string]string) string { 60 | return a.buildURL(fmt.Sprintf(product, id), q) 61 | } 62 | 63 | func (a *Admin) Products(q map[string]string) string { 64 | return a.buildURL(products, q) 65 | } 66 | 67 | func (a *Admin) Theme(id int64, q map[string]string) string { 68 | return a.buildURL(fmt.Sprintf(theme, id), q) 69 | } 70 | 71 | func (a *Admin) Themes(q map[string]string) string { 72 | return a.buildURL(themes, q) 73 | } 74 | -------------------------------------------------------------------------------- /cmd/charges/charges.go: -------------------------------------------------------------------------------- 1 | package charges 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/shopspring/decimal" 11 | shopify "github.com/bold-commerce/go-shopify/v3" 12 | "github.com/cheynewallace/tabby" 13 | "github.com/urfave/cli/v2" 14 | 15 | "github.com/ScreenStaring/shopify-dev-tools/cmd" 16 | ) 17 | 18 | var Cmd cli.Command 19 | 20 | func printRecordSeperator() { 21 | fmt.Printf("%s\n", strings.Repeat("-", 20)) 22 | } 23 | 24 | func printJSONL(charges []shopify.RecurringApplicationCharge) { 25 | for _, charge := range charges { 26 | printChargeJSONL(charge); 27 | } 28 | } 29 | 30 | func printChargeJSONL(charge interface{}) { 31 | line, err := json.Marshal(charge) 32 | if err != nil { 33 | panic(err) 34 | } 35 | 36 | fmt.Println(string(line)) 37 | } 38 | 39 | func printFormattedRecurringCharges(charges []shopify.RecurringApplicationCharge) { 40 | t := tabby.New() 41 | 42 | for _, charge := range charges { 43 | t.AddLine("Id", charge.ID) 44 | t.AddLine("Name", charge.Name) 45 | t.AddLine("Price", charge.Price) 46 | t.AddLine("Status", charge.Status) 47 | t.AddLine("Activated On", charge.ActivatedOn) 48 | t.AddLine("Confirmation URL", charge.ConfirmationURL) 49 | t.AddLine("Return URL", charge.DecoratedReturnURL) 50 | t.AddLine("Test", *charge.Test) 51 | t.AddLine("Created At", charge.CreatedAt) 52 | t.AddLine("Updated At", charge.UpdatedAt) 53 | t.Print() 54 | 55 | printRecordSeperator() 56 | } 57 | } 58 | 59 | func printFormattedApplicationCharges(charges []shopify.ApplicationCharge) { 60 | for _, charge := range charges { 61 | printFormattedApplicationCharge(&charge) 62 | printRecordSeperator() 63 | } 64 | } 65 | 66 | func printFormattedApplicationCharge(charge *shopify.ApplicationCharge) { 67 | t := tabby.New() 68 | 69 | t.AddLine("Id", charge.ID) 70 | t.AddLine("Name", charge.Name) 71 | t.AddLine("Price", charge.Price) 72 | t.AddLine("Status", charge.Status) 73 | t.AddLine("Confirmation URL", charge.ConfirmationURL) 74 | t.AddLine("Return URL", charge.DecoratedReturnURL) 75 | t.AddLine("Test", *charge.Test) 76 | t.AddLine("Created At", charge.CreatedAt) 77 | t.AddLine("Updated At", charge.UpdatedAt) 78 | t.Print() 79 | } 80 | 81 | 82 | func createCharge(c *cli.Context) error { 83 | var charge shopify.ApplicationCharge 84 | 85 | if(c.Args().Len() < 2) { 86 | return fmt.Errorf("You must supply charge name and price") 87 | } 88 | 89 | price, err := decimal.NewFromString(c.Args().Get(1)) 90 | if err != nil { 91 | return fmt.Errorf("Cannot create charge: invalid price %s", err) 92 | } 93 | 94 | charge.Price = &price 95 | charge.Name = c.Args().Get(0) 96 | 97 | test := c.Bool("test") 98 | charge.Test = &test 99 | 100 | returnURL := c.String("return-to") 101 | if len(returnURL) > 0 { 102 | charge.ReturnURL = returnURL 103 | } 104 | 105 | result, err := cmd.NewShopifyClient(c).ApplicationCharge.Create(charge) 106 | if err != nil { 107 | return fmt.Errorf("Cannot create charge: %s", err) 108 | } 109 | 110 | if(c.Bool("jsonl")) { 111 | printChargeJSONL(result) 112 | } else { 113 | printFormattedApplicationCharge(result) 114 | } 115 | 116 | return nil 117 | } 118 | 119 | func listOneTimeCharges(c *cli.Context, ids []int64) error { 120 | var err error 121 | var charges []shopify.ApplicationCharge 122 | 123 | client := cmd.NewShopifyClient(c) 124 | 125 | if(len(ids) > 0) { 126 | for _, id := range ids { 127 | charge, err := client.ApplicationCharge.Get(id, nil) 128 | if err != nil { 129 | return fmt.Errorf("Cannot get one-time charge %d: %s", id, err) 130 | } 131 | 132 | charges = append(charges, *charge) 133 | } 134 | } else { 135 | charges, err = client.ApplicationCharge.List(nil) 136 | if err != nil { 137 | return fmt.Errorf("Cannot list one-time charges: %s", err) 138 | } 139 | } 140 | 141 | if c.Bool("jsonl") { 142 | for _, charge := range charges { 143 | printChargeJSONL(charge); 144 | } 145 | } else { 146 | printFormattedApplicationCharges(charges) 147 | } 148 | 149 | return nil 150 | } 151 | 152 | func listRecurringCharges(c *cli.Context, ids []int64) error { 153 | var err error 154 | var charges []shopify.RecurringApplicationCharge 155 | 156 | client := cmd.NewShopifyClient(c) 157 | 158 | if(len(ids) > 0) { 159 | for _, id := range ids { 160 | charge, err := client.RecurringApplicationCharge.Get(id, nil) 161 | if err != nil { 162 | return fmt.Errorf("Cannot get recurring charge %d: %s", id, err) 163 | } 164 | 165 | charges = append(charges, *charge) 166 | } 167 | 168 | } else { 169 | charges, err = client.RecurringApplicationCharge.List(nil) 170 | if err != nil { 171 | return fmt.Errorf("Cannot list recurring charges: %s", err) 172 | } 173 | } 174 | 175 | if c.Bool("jsonl") { 176 | for _, charge := range charges { 177 | printChargeJSONL(charge); 178 | } 179 | } else { 180 | printFormattedRecurringCharges(charges) 181 | } 182 | 183 | 184 | return nil 185 | } 186 | 187 | func listCharges(c *cli.Context) error { 188 | var ids []int64 189 | 190 | if c.NArg() > 0 { 191 | for i := 0; i < c.NArg(); i++ { 192 | id, err := strconv.ParseInt(c.Args().Get(i), 10, 64) 193 | if err != nil { 194 | return fmt.Errorf("Charge id '%s' invalid: must be an int", c.Args().Get(0)) 195 | } 196 | 197 | ids = append(ids, id) 198 | } 199 | 200 | } 201 | 202 | if (c.Bool("one-time")) { 203 | return listOneTimeCharges(c, ids) 204 | } else { 205 | return listRecurringCharges(c, ids) 206 | } 207 | 208 | return nil 209 | } 210 | 211 | func init() { 212 | listFlags := []cli.Flag{ 213 | &cli.BoolFlag{ 214 | Name: "jsonl", 215 | Aliases: []string{"j"}, 216 | Usage: "Output the charges in JSONL format", 217 | }, 218 | &cli.BoolFlag{ 219 | Name: "one-time", 220 | Aliases: []string{"1"}, 221 | Usage: "List one-time charges (default is recurring)", 222 | }, 223 | } 224 | 225 | createFlags := []cli.Flag{ 226 | &cli.StringFlag{ 227 | Name: "return-to", 228 | Aliases: []string{"r"}, 229 | Usage: "URL to redirect user to after charge is accepted", 230 | }, 231 | &cli.BoolFlag{ 232 | Name: "test", 233 | Aliases: []string{"t"}, 234 | Usage: "Make the charge a test charge", 235 | }, 236 | // lib does not support 237 | // &cli.StringFlag{ 238 | // Name: "currency", 239 | // Aliases: []string{"c"}, 240 | // Usage: "Currency to use", 241 | // }, 242 | } 243 | 244 | Cmd = cli.Command{ 245 | Name: "charges", 246 | Aliases: []string{"c", "ch"}, 247 | Usage: "Do things with charges", 248 | Subcommands: []*cli.Command{ 249 | { 250 | Name: "ls", 251 | Aliases: []string{"l"}, 252 | Usage: "List the shop's charges or the charges given by the specified IDs", 253 | ArgsUsage: "[ID [ID ...]]", 254 | Flags: append(cmd.Flags, listFlags...), 255 | Action: listCharges, 256 | }, 257 | { 258 | Name: "create", 259 | Aliases: []string{"c"}, 260 | Usage: "Create a one-time charge (Application Charge)", 261 | ArgsUsage: "NAME PRICE", 262 | Flags: append(cmd.Flags, createFlags...), 263 | Action: createCharge, 264 | }, 265 | }, 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | 11 | shopify "github.com/bold-commerce/go-shopify/v3" 12 | "github.com/urfave/cli/v2" 13 | "github.com/urfave/cli/v2/altsrc" 14 | ) 15 | 16 | var Flags []cli.Flag 17 | var accessTokenCommand = regexp.MustCompile(`\A\s*<\s*(.+)\z`) 18 | 19 | func NewShopifyClient(c *cli.Context) *shopify.Client { 20 | var logging shopify.Option 21 | 22 | app := shopify.App{ 23 | ApiKey: c.String("api-key"), 24 | Password: c.String("api-password"), 25 | } 26 | 27 | shop := c.String("shop") 28 | 29 | if c.Bool("verbose") { 30 | logging = shopify.WithLogger(&shopify.LeveledLogger{Level: shopify.LevelDebug}) 31 | return shopify.NewClient(app, shop, LookupAccessToken(shop, c.String("access-token")), logging) 32 | } 33 | 34 | return shopify.NewClient(app, shop, LookupAccessToken(shop, c.String("access-token"))) 35 | } 36 | 37 | func ParseIntAt(c *cli.Context, pos int) (int64, error) { 38 | return strconv.ParseInt(c.Args().Get(pos), 10, 64) 39 | } 40 | 41 | func PrintSeparator() { 42 | fmt.Printf("%s\n", strings.Repeat("-", 20)) 43 | } 44 | 45 | func LookupAccessToken(shop, token string) string { 46 | match := accessTokenCommand.FindStringSubmatch(token) 47 | if len(match) == 0 { 48 | return token 49 | } 50 | 51 | out, err := exec.Command(match[1], shop).Output() 52 | // FIXME: return an error. Exit should be done in caller 53 | if err != nil { 54 | fmt.Fprintf(os.Stderr, "access token command failed: %s\n", err) 55 | os.Exit(2) 56 | } 57 | 58 | return strings.TrimSuffix(string(out), "\n") 59 | } 60 | 61 | func init() { 62 | Flags = []cli.Flag{ 63 | &cli.BoolFlag{ 64 | Name: "verbose", 65 | Usage: "Output Shopify API request/response", 66 | }, 67 | altsrc.NewStringFlag( 68 | &cli.StringFlag{ 69 | Name: "shop", 70 | Usage: "Shopify domain or shop name to perform command against", 71 | Required: true, 72 | EnvVars: []string{"SHOPIFY_SHOP"}, 73 | }, 74 | ), 75 | &cli.StringFlag{ 76 | Name: "api-password", 77 | Usage: "Shopify API password", 78 | EnvVars: []string{"SHOPIFY_API_PASSWORD"}, 79 | }, 80 | &cli.StringFlag{ 81 | Name: "access-token", 82 | Usage: "Shopify access token for shop", 83 | EnvVars: []string{"SHOPIFY_ACCESS_TOKEN", "SHOPIFY_API_TOKEN"}, 84 | }, 85 | &cli.StringFlag{ 86 | Name: "api-key", 87 | Usage: "Shopify API key to for shop", 88 | EnvVars: []string{"SHOPIFY_API_KEY"}, 89 | }, 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /cmd/gql/gql.go: -------------------------------------------------------------------------------- 1 | package gql 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "os" 8 | 9 | "github.com/ScreenStaring/shopify-dev-tools/cmd" 10 | "github.com/ScreenStaring/shopify-dev-tools/gql" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | var Cmd cli.Command 15 | 16 | func findQuery(c *cli.Context) (string, error) { 17 | if c.NArg() == 0 { 18 | query, err := io.ReadAll(os.Stdin) 19 | if err != nil { 20 | return "", fmt.Errorf("Cannot read from stdin: %s", err) 21 | } 22 | 23 | // Ensure ^D doesn't show in output when reading from stdin 24 | fmt.Print("\n") 25 | 26 | return string(query), nil 27 | } 28 | 29 | file := c.Args().Get(0) 30 | 31 | query, err := ioutil.ReadFile(file) 32 | if err != nil { 33 | return "", fmt.Errorf("Cannot read file %s: %s", file, err) 34 | } 35 | 36 | return string(query), nil 37 | } 38 | 39 | func queryAction(c *cli.Context) error { 40 | shop := c.String("shop") 41 | client := gql.NewClient(shop, cmd.LookupAccessToken(shop, c.String("access-token")), c.String("api-version")) 42 | 43 | query, err := findQuery(c) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | result, err := client.Query(query) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | err = result.JsonIndentWriter(os.Stdout, "", " ") 54 | if err != nil { 55 | return fmt.Errorf("Cannot serialize GraphQL JSON response: %s", err) 56 | } 57 | 58 | return nil 59 | } 60 | 61 | func init() { 62 | flags := []cli.Flag{ 63 | &cli.StringFlag{ 64 | Name: "api-version", 65 | Aliases: []string{"a"}, 66 | Usage: "API version to use; default is a versionless call", 67 | }, 68 | } 69 | 70 | Cmd = cli.Command{ 71 | Name: "graphql", 72 | Aliases: []string{"gql"}, 73 | ArgsUsage: "[query-file.graphql]", 74 | Usage: "Run a GraphQL query against the Admin API", 75 | Description: "If query-file.graphql is not given query is read from stdin", 76 | Flags: append(cmd.Flags, flags...), 77 | Action: queryAction, 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /cmd/metafields/metafields.go: -------------------------------------------------------------------------------- 1 | package metafields 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | shopify "github.com/bold-commerce/go-shopify/v3" 11 | "github.com/cheynewallace/tabby" 12 | "github.com/urfave/cli/v2" 13 | 14 | "github.com/ScreenStaring/shopify-dev-tools/cmd" 15 | "github.com/ScreenStaring/shopify-dev-tools/gql/storefront" 16 | ) 17 | 18 | type metafieldOptions struct { 19 | Namespace string `url:"namespace"` 20 | Key string `url:"key"` 21 | JSONL bool 22 | OrderBy []string 23 | } 24 | 25 | var Cmd cli.Command 26 | 27 | var sortByFieldFuncs = map[string]lessFunc{ 28 | "namespace": byNamespaceAsc, 29 | "namespace:asc": byNamespaceAsc, 30 | "namespace:desc": byNamespaceDesc, 31 | "key": byKeyAsc, 32 | "key:asc": byKeyAsc, 33 | "key:desc": byKeyDesc, 34 | "create": byCreatedAtAsc, 35 | "create:asc": byCreatedAtAsc, 36 | "create:desc": byCreatedAtDesc, 37 | "created": byCreatedAtAsc, 38 | "created:asc": byCreatedAtAsc, 39 | "created:desc": byCreatedAtDesc, 40 | "update": byUpdatedAtAsc, 41 | "update:asc": byUpdatedAtAsc, 42 | "update:desc": byUpdatedAtDesc, 43 | "updated": byUpdatedAtAsc, 44 | "updated:asc": byUpdatedAtAsc, 45 | "updated:desc": byUpdatedAtDesc, 46 | } 47 | 48 | func contextToOptions(c *cli.Context) metafieldOptions { 49 | return metafieldOptions{ 50 | Key: c.String("key"), 51 | Namespace: c.String("namespace"), 52 | OrderBy: c.StringSlice("order"), 53 | JSONL: c.Bool("jsonl"), 54 | } 55 | } 56 | 57 | func printMetafields(metafields []shopify.Metafield, options metafieldOptions) { 58 | if options.JSONL { 59 | printJSONL(metafields) 60 | } else { 61 | printFormatted(metafields, options) 62 | } 63 | } 64 | 65 | func printJSONL(metafields []shopify.Metafield) { 66 | for _, metafield := range metafields { 67 | line, err := json.Marshal(metafield) 68 | if err != nil { 69 | panic(err) 70 | } 71 | 72 | fmt.Println(string(line)) 73 | } 74 | 75 | } 76 | 77 | func printFormatted(metafields []shopify.Metafield, options metafieldOptions) { 78 | sortMetafields(metafields, options) 79 | 80 | t := tabby.New() 81 | for _, metafield := range metafields { 82 | t.AddLine("Id", metafield.ID) 83 | t.AddLine("Gid", metafield.AdminGraphqlAPIID) 84 | t.AddLine("Namespace", metafield.Namespace) 85 | t.AddLine("Key", metafield.Key) 86 | t.AddLine("Description", metafield.Description) 87 | // format JSON strings 88 | // also check for string types that look like json: /\A\{"[^"]+":/ or /\A[/ and /\]\Z/ 89 | t.AddLine("Value", metafield.Value) 90 | t.AddLine("Type", metafield.Type) 91 | t.AddLine("Created", metafield.CreatedAt) 92 | t.AddLine("Updated", metafield.UpdatedAt) 93 | t.Print() 94 | fmt.Printf("%s\n", strings.Repeat("-", 20)) 95 | } 96 | } 97 | 98 | // Cannot sort storefront metafields from GQL 99 | func sortMetafields(metafields []shopify.Metafield, options metafieldOptions) { 100 | var funcs []lessFunc 101 | 102 | if len(options.OrderBy) != 0 { 103 | for _, field := range options.OrderBy { 104 | funcs = append(funcs, sortByFieldFuncs[field]) 105 | } 106 | } else { 107 | if options.Namespace != "" { 108 | funcs = []lessFunc{byKeyAsc} 109 | } else if options.Key != "" { 110 | funcs = []lessFunc{byNamespaceAsc} 111 | } else { 112 | funcs = []lessFunc{byNamespaceAsc, byKeyAsc} 113 | } 114 | } 115 | 116 | sorter := metafieldsSorter{less: funcs} 117 | sorter.Sort(metafields) 118 | } 119 | 120 | func customerAction(c *cli.Context) error { 121 | if c.NArg() == 0 { 122 | return errors.New("Customer id required") 123 | } 124 | 125 | id, err := strconv.ParseInt(c.Args().Get(0), 10, 64) 126 | if err != nil { 127 | return fmt.Errorf("Customer id '%s' invalid: must be an int", c.Args().Get(0)) 128 | } 129 | 130 | options := contextToOptions(c) 131 | metafields, err := cmd.NewShopifyClient(c).Customer.ListMetafields(id, options) 132 | if err != nil { 133 | return fmt.Errorf("Cannot list metafields for customer: %s", err) 134 | } 135 | 136 | printMetafields(metafields, options) 137 | return nil 138 | } 139 | 140 | func productAction(c *cli.Context) error { 141 | if c.NArg() == 0 { 142 | return errors.New("Product id required") 143 | } 144 | 145 | // TODO: accept handle too (maybe use regex to detect? But handle can be all digits too) 146 | id, err := strconv.ParseInt(c.Args().Get(0), 10, 64) 147 | if err != nil { 148 | return fmt.Errorf("Product id '%s' invalid: must be an int", c.Args().Get(0)) 149 | } 150 | 151 | options := contextToOptions(c) 152 | metafields, err := cmd.NewShopifyClient(c).Product.ListMetafields(id, options) 153 | if err != nil { 154 | return fmt.Errorf("Cannot list metafields for product %d: %s", id, err) 155 | } 156 | 157 | printMetafields(metafields, options) 158 | return nil 159 | } 160 | 161 | func shopAction(c *cli.Context) error { 162 | options := contextToOptions(c) 163 | metafields, err := cmd.NewShopifyClient(c).Metafield.List(options) 164 | if err != nil { 165 | return fmt.Errorf("Cannot list metafields for shop: %s", err) 166 | } 167 | 168 | printFormatted(metafields, options) 169 | 170 | return nil 171 | } 172 | 173 | func variantAction(c *cli.Context) error { 174 | if c.NArg() == 0 { 175 | return errors.New("Variant id required") 176 | } 177 | 178 | id, err := strconv.ParseInt(c.Args().Get(0), 10, 64) 179 | if err != nil { 180 | return fmt.Errorf("Variant id '%s' invalid: must be an int", c.Args().Get(0)) 181 | } 182 | 183 | options := contextToOptions(c) 184 | metafields, err := cmd.NewShopifyClient(c).Variant.ListMetafields(id, options) 185 | if err != nil { 186 | return fmt.Errorf("Cannot list metafields for variant %d: %s", id, err) 187 | } 188 | 189 | printMetafields(metafields, options) 190 | 191 | return nil 192 | } 193 | 194 | func storefrontListAction(c *cli.Context) error { 195 | shop := c.String("shop") 196 | token := cmd.LookupAccessToken(shop, c.String("access-token")) 197 | 198 | metafields, err := storefront.New(shop, token).List() 199 | if err != nil { 200 | return err 201 | } 202 | 203 | //fmt.Printf("%+v\n", metafields) 204 | 205 | t := tabby.New() 206 | for _, metafield := range metafields { 207 | t.AddLine("Id", metafield["legacyResourceId"]) 208 | t.AddLine("Gid", metafield["id"]) 209 | t.AddLine("Namespace", metafield["namespace"]) 210 | t.AddLine("Key", metafield["key"]) 211 | t.AddLine("Owner Type", metafield["ownerType"]) 212 | t.AddLine("Created", metafield["createdAt"]) 213 | t.AddLine("Updated", metafield["updatedAt"]) 214 | t.Print() 215 | fmt.Printf("%s\n", strings.Repeat("-", 20)) 216 | } 217 | 218 | return nil 219 | } 220 | 221 | func storefrontEnableAction(c *cli.Context) error { 222 | shop := c.String("shop") 223 | token := cmd.LookupAccessToken(shop, c.String("access-token")) 224 | 225 | if(c.Args().Len() < 2) { 226 | return fmt.Errorf("You must supply a key and owner") 227 | } 228 | 229 | id, err := storefront.New(shop, token).Enable(c.Args().Get(0), c.Args().Get(1)) 230 | if err != nil { 231 | return err 232 | } 233 | 234 | fmt.Printf("Created %s \n", id) 235 | 236 | return nil 237 | } 238 | 239 | func init() { 240 | metafieldFlags := []cli.Flag{ 241 | &cli.StringFlag{ 242 | Name: "key", 243 | Aliases: []string{"k"}, 244 | Usage: "Find metafields with the given key", 245 | }, 246 | &cli.StringFlag{ 247 | Name: "namespace", 248 | Aliases: []string{"n"}, 249 | Usage: "Find metafields with the given namespace", 250 | }, 251 | &cli.StringSliceFlag{ 252 | Name: "order", 253 | Aliases: []string{"o"}, 254 | Usage: "Order metafields by the given properties", 255 | }, 256 | &cli.BoolFlag{ 257 | Name: "jsonl", 258 | Aliases: []string{"j"}, 259 | Usage: "Output the metafields in JSONL format", 260 | }, 261 | } 262 | 263 | Cmd = cli.Command{ 264 | Name: "metafield", 265 | Aliases: []string{"m", "meta"}, 266 | Usage: "Metafield utilities", 267 | Subcommands: []*cli.Command{ 268 | { 269 | Name: "customer", 270 | Flags: append(cmd.Flags, metafieldFlags...), 271 | Aliases: []string{"c"}, 272 | Action: customerAction, 273 | Usage: "List metafields for the given customer", 274 | }, 275 | { 276 | Name: "product", 277 | Flags: append(cmd.Flags, metafieldFlags...), 278 | Aliases: []string{"products", "prod", "p"}, 279 | Action: productAction, 280 | Usage: "List metafields for the given product", 281 | }, 282 | { 283 | Name: "shop", 284 | Flags: append(cmd.Flags, metafieldFlags...), 285 | Aliases: []string{"s"}, 286 | Action: shopAction, 287 | Usage: "List metafields for the given shop", 288 | }, 289 | { 290 | Name: "storefront", 291 | Aliases: []string{"sf"}, 292 | Usage: "Storefront API utilities", 293 | Subcommands: []*cli.Command{ 294 | { 295 | Name: "ls", 296 | Flags: append(cmd.Flags, metafieldFlags...), 297 | Usage: "List accessible metafields", 298 | Action: storefrontListAction, 299 | }, 300 | { 301 | Name: "enable", 302 | Aliases: []string{"e"}, 303 | Usage: "Make a metafield accessible", 304 | ArgsUsage: "NAMESPACE.KEY OWNER", 305 | Flags: cmd.Flags, 306 | Action: storefrontEnableAction, 307 | }, 308 | }, 309 | }, 310 | { 311 | Name: "variant", 312 | Aliases: []string{"var", "v"}, 313 | Flags: append(cmd.Flags, metafieldFlags...), 314 | Action: variantAction, 315 | Usage: "List metafields for the given variant", 316 | }, 317 | }, 318 | } 319 | 320 | } 321 | -------------------------------------------------------------------------------- /cmd/metafields/sorting.go: -------------------------------------------------------------------------------- 1 | package metafields 2 | 3 | import ( 4 | shopify "github.com/bold-commerce/go-shopify/v3" 5 | "sort" 6 | "strings" 7 | ) 8 | 9 | type lessFunc func(mf1, mf2 *shopify.Metafield) int 10 | 11 | type metafieldsSorter struct { 12 | metafields []shopify.Metafield 13 | less []lessFunc 14 | } 15 | 16 | func (ms *metafieldsSorter) Len() int { 17 | return len(ms.metafields) 18 | } 19 | 20 | func (ms *metafieldsSorter) Swap(i, j int) { 21 | ms.metafields[i], ms.metafields[j] = ms.metafields[j], ms.metafields[i] 22 | } 23 | 24 | func (mf *metafieldsSorter) Less(i, j int) bool { 25 | less := false 26 | for _, fx := range mf.less { 27 | order := fx(&mf.metafields[i], &mf.metafields[j]) 28 | if order == 0 { 29 | less = false 30 | continue 31 | } 32 | 33 | less = order == -1 34 | break 35 | } 36 | 37 | return less 38 | } 39 | 40 | func (ms *metafieldsSorter) Sort(metafields []shopify.Metafield) { 41 | ms.metafields = metafields 42 | sort.Sort(ms) 43 | } 44 | 45 | func byNamespaceAsc(mf1, mf2 *shopify.Metafield) int { 46 | return strings.Compare(strings.ToLower(mf1.Namespace), strings.ToLower(mf2.Namespace)) 47 | } 48 | 49 | func byNamespaceDesc(mf1, mf2 *shopify.Metafield) int { 50 | return byNamespaceAsc(mf2, mf1) 51 | } 52 | 53 | func byKeyAsc(mf1, mf2 *shopify.Metafield) int { 54 | return strings.Compare(strings.ToLower(mf1.Key), strings.ToLower(mf2.Key)) 55 | } 56 | 57 | func byKeyDesc(mf1, mf2 *shopify.Metafield) int { 58 | return byKeyAsc(mf2, mf1) 59 | } 60 | 61 | func byCreatedAtAsc(mf1, mf2 *shopify.Metafield) int { 62 | if mf1.CreatedAt.Before(*mf2.CreatedAt) { 63 | return -1 64 | } 65 | 66 | if mf1.CreatedAt.After(*mf2.CreatedAt) { 67 | return 1 68 | } 69 | 70 | return 0 71 | } 72 | 73 | func byCreatedAtDesc(mf1, mf2 *shopify.Metafield) int { 74 | return byUpdatedAtAsc(mf2, mf1) 75 | } 76 | 77 | func byUpdatedAtAsc(mf1, mf2 *shopify.Metafield) int { 78 | if mf1.UpdatedAt.Before(*mf2.UpdatedAt) { 79 | return -1 80 | } 81 | 82 | if mf1.UpdatedAt.After(*mf2.UpdatedAt) { 83 | return 1 84 | } 85 | 86 | return 0 87 | } 88 | 89 | func byUpdatedAtDesc(mf1, mf2 *shopify.Metafield) int { 90 | return byUpdatedAtAsc(mf2, mf1) 91 | } 92 | -------------------------------------------------------------------------------- /cmd/orders/orders.go: -------------------------------------------------------------------------------- 1 | package orders 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | shopify "github.com/bold-commerce/go-shopify/v3" 8 | "github.com/urfave/cli/v2" 9 | "github.com/ScreenStaring/shopify-dev-tools/cmd" 10 | "github.com/cheynewallace/tabby" 11 | ) 12 | 13 | var Cmd cli.Command 14 | 15 | // TODO: implement 16 | type listOrdersOptions struct { 17 | Ids []int64 `url:"ids,comma,omitempty"` 18 | CreatedAtMin time.Time `url:"created_at_min,omitempty"` 19 | } 20 | 21 | func userAgentAction(c *cli.Context) error { 22 | if(c.Args().Len() == 0) { 23 | return fmt.Errorf("You must supply an order id") 24 | } 25 | 26 | id, err := cmd.ParseIntAt(c, 0) 27 | if err != nil { 28 | return fmt.Errorf("Order id '%s' is invalid: must be an int", c.Args().Get(0)) 29 | } 30 | 31 | order, err := cmd.NewShopifyClient(c).Order.Get(id, nil) 32 | if err != nil { 33 | return fmt.Errorf("Cannot find order: %s", err) 34 | } 35 | 36 | t := tabby.New() 37 | t.AddLine("Id", order.ID) 38 | t.AddLine("User Agent", order.ClientDetails.UserAgent) 39 | t.AddLine("Display", fmt.Sprintf("%dx%d", order.ClientDetails.BrowserWidth, order.ClientDetails.BrowserHeight)) 40 | t.AddLine("Accept Language", order.ClientDetails.AcceptLanguage) 41 | t.AddLine("IP", order.BrowserIp) 42 | t.AddLine("Session", order.ClientDetails.SessionHash) 43 | t.Print() 44 | 45 | cmd.PrintSeparator() 46 | 47 | return nil 48 | } 49 | 50 | func listAction(c *cli.Context) error { 51 | var options listOrdersOptions 52 | 53 | 54 | if c.NArg() > 0 { 55 | for i := 0; i < c.NArg(); i++ { 56 | id, err := cmd.ParseIntAt(c, i) 57 | if err != nil { 58 | return fmt.Errorf("Order id '%s' invalid: must be an int", c.Args().Get(i)) 59 | } 60 | 61 | options.Ids = append(options.Ids, id) 62 | } 63 | 64 | } 65 | 66 | orders, err := cmd.NewShopifyClient(c).Order.List(options) 67 | if err != nil { 68 | return fmt.Errorf("Cannot list orders: %s", err) 69 | } 70 | 71 | 72 | printOrders(orders) 73 | 74 | return nil 75 | } 76 | 77 | func printOrders(orders []shopify.Order) { 78 | t := tabby.New() 79 | for _, order := range orders { 80 | t.AddLine("Id", order.ID) 81 | t.AddLine("Name", order.Name) 82 | t.AddLine("Created At", order.CreatedAt) 83 | t.AddLine("Updated At", order.UpdatedAt) 84 | t.AddLine("Canceled At", order.CancelledAt) 85 | t.AddLine("Closed At", order.ClosedAt) 86 | t.AddLine("Order Status URL", order.OrderStatusUrl) 87 | 88 | note := order.Note 89 | if len(note) > 0 { 90 | note = "\"" + note + "\"" 91 | } 92 | 93 | t.AddLine("Note", note) 94 | t.Print() 95 | 96 | fmt.Println("Line Items") 97 | printLineItems(order.LineItems) 98 | fmt.Print("\n") 99 | 100 | cmd.PrintSeparator(); 101 | 102 | } 103 | 104 | } 105 | 106 | func truncate(val string) string { 107 | max := 25 108 | 109 | if len(val) < max { 110 | return val 111 | } 112 | 113 | cut := val[0:max] 114 | 115 | if len(cut) < len(val) { 116 | cut += "…" 117 | } 118 | 119 | return cut 120 | } 121 | 122 | func printLineItems(lines []shopify.LineItem) { 123 | x := tabby.New() 124 | x.AddHeader("ID", "Product ID", "Variant ID", "SKU", "Title", "Quantity", "Status") 125 | 126 | for _, line := range lines { 127 | x.AddLine( 128 | line.ID, 129 | line.ProductID, 130 | line.VariantID, 131 | line.SKU, 132 | truncate(line.Name), 133 | line.Quantity, 134 | line.FulfillmentStatus, 135 | ) 136 | } 137 | 138 | x.Print() 139 | } 140 | 141 | func init() { 142 | Cmd = cli.Command{ 143 | Name: "orders", 144 | Aliases: []string{"o"}, 145 | Usage: "Information about orders", 146 | Subcommands: []*cli.Command{ 147 | { 148 | Name: "useragent", 149 | Aliases: []string{"ua"}, 150 | Usage: "Info about the web browser used to place the order", 151 | Flags: cmd.Flags, 152 | Action: userAgentAction, 153 | }, 154 | { 155 | Name: "ls", 156 | Usage: "List the shop's orders or the orders given by the specified IDs", 157 | Flags: cmd.Flags, 158 | Action: listAction, 159 | }, 160 | }, 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /cmd/products/products.go: -------------------------------------------------------------------------------- 1 | package products 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "fmt" 7 | "reflect" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | shopify "github.com/bold-commerce/go-shopify/v3" 13 | "github.com/cheynewallace/tabby" 14 | "github.com/urfave/cli/v2" 15 | 16 | "github.com/ScreenStaring/shopify-dev-tools/cmd" 17 | ) 18 | 19 | var Cmd cli.Command 20 | 21 | // func createProducts(c *cli.Context) error { 22 | // if c.NArg() == 0 { 23 | // return errors.New("CSV required") 24 | // } 25 | 26 | // return nil 27 | // } 28 | 29 | type listProductOptions struct { 30 | Fields []string `url:"fields,comma,omitempty"` 31 | Ids []int64 `url:"ids,comma,omitempty"` 32 | Limit int64 `url:"limit,omitempty"` 33 | Status string `url:"status,omitempty"` 34 | UpdatedAtMin time.Time `url:"updated_at_min,omitempty"` 35 | } 36 | 37 | func printJSONL(products []shopify.Product) { 38 | for _, product := range products { 39 | line, err := json.Marshal(product) 40 | if err != nil { 41 | panic(err) 42 | } 43 | 44 | fmt.Println(string(line)) 45 | } 46 | } 47 | 48 | func normalizeField(name string) string { 49 | return strings.ReplaceAll(strings.ToLower(name), " ", "") 50 | } 51 | 52 | func isFieldToPrint(field string, selectedFields []string) bool { 53 | for _, f := range selectedFields { 54 | if f == field { 55 | return true 56 | } 57 | } 58 | 59 | return false 60 | } 61 | 62 | func printFormatted(products []shopify.Product, fieldsToPrint []string) { 63 | t := tabby.New() 64 | normalizedFieldsToPrint := []string{} 65 | 66 | for _, field := range fieldsToPrint { 67 | normalizedFieldsToPrint = append(normalizedFieldsToPrint, normalizeField(field)) 68 | } 69 | 70 | for _, product := range products { 71 | s := reflect.ValueOf(&product).Elem() 72 | 73 | for i := 0; i < s.NumField(); i++ { 74 | field := s.Type().Field(i).Name 75 | normalizedField := normalizeField(field) 76 | 77 | if len(fieldsToPrint) > 0 { 78 | if isFieldToPrint(normalizedField, normalizedFieldsToPrint) { 79 | t.AddLine(field, s.Field(i).Interface()) 80 | } 81 | } else { 82 | t.AddLine(field, s.Field(i).Interface()) 83 | } 84 | } 85 | 86 | t.Print() 87 | fmt.Printf("%s\n", strings.Repeat("-", 20)) 88 | } 89 | } 90 | 91 | func listProducts(c *cli.Context) error { 92 | var products []shopify.Product 93 | var options listProductOptions 94 | 95 | if c.NArg() > 0 { 96 | for i := 0; i < c.NArg(); i++ { 97 | id, err := strconv.ParseInt(c.Args().Get(i), 10, 64) 98 | if err != nil { 99 | return fmt.Errorf("Product id '%s' invalid: must be an int", c.Args().Get(0)) 100 | } 101 | 102 | options.Ids = append(options.Ids, id) 103 | } 104 | 105 | } else { 106 | if len(c.String("status")) > 0 { 107 | options.Status = c.String("status") 108 | } 109 | 110 | if c.Int64("limit") > 0 { 111 | options.Limit = c.Int64("limit") 112 | } 113 | } 114 | 115 | if len(c.String("fields")) > 0 { 116 | options.Fields = strings.Split(c.String("fields"), ",") 117 | } 118 | 119 | products, err := cmd.NewShopifyClient(c).Product.List(options) 120 | if err != nil { 121 | return fmt.Errorf("Cannot list products: %s", err) 122 | } 123 | 124 | if c.Bool("jsonl") { 125 | printJSONL(products) 126 | } else { 127 | printFormatted(products, options.Fields) 128 | } 129 | 130 | return nil 131 | } 132 | 133 | func init() { 134 | productFlags := []cli.Flag{ 135 | // &cli.StringSliceFlag{ 136 | // Name: "order", 137 | // Aliases: []string{"o"}, 138 | // Usage: "Order products by the given properties", 139 | // }, 140 | &cli.StringFlag{ 141 | Name: "fields", 142 | Aliases: []string{"f"}, 143 | Usage: "Comma separated list of fields to output", 144 | EnvVars: []string{"SHOPIFY_PRODUCT_FIELDS"}, 145 | }, 146 | &cli.Int64Flag{ 147 | Name: "limit", 148 | Aliases: []string{"l"}, 149 | Value: 10, 150 | }, 151 | &cli.StringFlag{ 152 | Name: "status", 153 | Aliases: []string{"s"}, 154 | }, 155 | &cli.BoolFlag{ 156 | Name: "jsonl", 157 | Aliases: []string{"j"}, 158 | Usage: "Output the products in JSONL format", 159 | }, 160 | } 161 | 162 | Cmd = cli.Command{ 163 | Name: "products", 164 | Aliases: []string{"p"}, 165 | Usage: "Do things with products", 166 | Subcommands: []*cli.Command{ 167 | { 168 | Name: "ls", 169 | Aliases: []string{"l"}, 170 | Usage: "List some of a shop's products or the products given by the specified IDs", 171 | ArgsUsage: "[ID [ID ...]]", 172 | Flags: append(cmd.Flags, productFlags...), 173 | Action: listProducts, 174 | }, 175 | // { 176 | // Name: "create", 177 | // Aliases: []string{"c"}, 178 | // Usage: "Create products from the give Shopify CSV", 179 | // Flags: cmd.Flags, 180 | // Action: createProducts, 181 | // }, 182 | }, 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /cmd/scripttags/scripttags.go: -------------------------------------------------------------------------------- 1 | package scripttags; 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/urfave/cli/v2" 10 | shopify "github.com/bold-commerce/go-shopify/v3" 11 | "github.com/cheynewallace/tabby" 12 | 13 | "github.com/ScreenStaring/shopify-dev-tools/cmd" 14 | ) 15 | 16 | type listScriptTagOptions struct { 17 | Src string `url:"src"` 18 | } 19 | 20 | var Cmd cli.Command 21 | // 22 | // Match https://foo.com or //foo.com 23 | // According to GQL docs this can be *any* URI: 24 | // https://shopify.dev/api/admin-graphql/2022-01/mutations/scriptTagCreate 25 | // 26 | var scriptTagURL = regexp.MustCompile(`(?i)\A(?:https:)?//[\da-z]`) 27 | 28 | func deleteAction(c *cli.Context) error { 29 | if(c.Args().Len() == 0) { 30 | return fmt.Errorf("You must supply an script tag id or URL") 31 | } 32 | 33 | var ids[] int64 34 | var err error 35 | 36 | client := cmd.NewShopifyClient(c) 37 | 38 | if(scriptTagURL.MatchString(c.Args().Get(0))) { 39 | options := listScriptTagOptions{Src: c.Args().Get(0)} 40 | tags, err := client.ScriptTag.List(options) 41 | 42 | if err != nil { 43 | return fmt.Errorf("Cannot list script tag with URL %s: %s", options.Src, err) 44 | } 45 | 46 | if len(tags) == 0 { 47 | return fmt.Errorf("Cannot find script tag with URL %s", options.Src) 48 | } 49 | 50 | for _, tag := range tags { 51 | ids = append(ids, tag.ID) 52 | } 53 | } else { 54 | id, err := strconv.ParseInt(c.Args().Get(0), 10, 64) 55 | if err != nil { 56 | return fmt.Errorf("Script tag id '%s' is invalid: must be an int", c.Args().Get(0)) 57 | } 58 | 59 | ids = append(ids, id) 60 | } 61 | 62 | for _, id := range ids { 63 | err = client.ScriptTag.Delete(id) 64 | if err != nil { 65 | return fmt.Errorf("Cannot delete script tag: %s", err) 66 | } 67 | 68 | fmt.Printf("Script tag %d deleted\n", id) 69 | } 70 | 71 | return nil 72 | } 73 | 74 | func listAction(c *cli.Context) error { 75 | hooks, err := cmd.NewShopifyClient(c).ScriptTag.List(nil) 76 | if err != nil { 77 | return fmt.Errorf("Cannot list ScriptTags: %s", err) 78 | } 79 | 80 | if c.Bool("jsonl") { 81 | //printJSONL(hooks) 82 | } else { 83 | printFormatted(hooks) 84 | } 85 | 86 | return nil 87 | } 88 | 89 | func printFormatted(webhooks []shopify.ScriptTag) { 90 | t := tabby.New() 91 | for _, webhook := range webhooks { 92 | t.AddLine("Id", webhook.ID) 93 | t.AddLine("Src", webhook.Src) 94 | t.AddLine("Event", webhook.Event) 95 | t.AddLine("Display Scope", webhook.DisplayScope) 96 | t.AddLine("Created", webhook.CreatedAt) 97 | t.AddLine("Updated", webhook.UpdatedAt) 98 | t.Print() 99 | 100 | fmt.Printf("%s\n", strings.Repeat("-", 20)) 101 | } 102 | } 103 | 104 | func init() { 105 | Cmd = cli.Command{ 106 | Name: "scripttags", 107 | Usage: "ScriptTag utilities", 108 | Subcommands: []*cli.Command{ 109 | { 110 | Name: "delete", 111 | Aliases: []string{"del", "rm", "d"}, 112 | Flags: append(cmd.Flags), 113 | Action: deleteAction, 114 | Usage: "Delete the given ScriptTag", 115 | }, 116 | { 117 | Name: "list", 118 | Aliases: []string{"ls"}, 119 | Flags: append(cmd.Flags), 120 | Action: listAction, 121 | Usage: "List scripttags for the given shop", 122 | }, 123 | }, 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /cmd/shop/shop.go: -------------------------------------------------------------------------------- 1 | package shop 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "regexp" 7 | "sort" 8 | "strings" 9 | 10 | "github.com/urfave/cli/v2" 11 | "github.com/ScreenStaring/shopify-dev-tools/cmd" 12 | "github.com/cheynewallace/tabby" 13 | ) 14 | 15 | var Cmd cli.Command 16 | 17 | // Some low-budget formatting 18 | func formatField(field string) string { 19 | re := regexp.MustCompile("([a-z])([A-Z])") 20 | name := strings.Replace(field, "API", "Api", 1) 21 | name = re.ReplaceAllString(name, "$1 $2") 22 | name = strings.Replace(name, " At", "", 1) 23 | 24 | return name 25 | } 26 | 27 | func accessAction(c *cli.Context) error { 28 | scopes, err := cmd.NewShopifyClient(c).AccessScopes.List(nil) 29 | if err != nil { 30 | return fmt.Errorf("Cannot get access scopes: %s", err) 31 | } 32 | 33 | if len(scopes) == 0 { 34 | fmt.Println("No access scopes defined") 35 | return nil 36 | } 37 | 38 | t := tabby.New() 39 | t.AddHeader("Scope") 40 | 41 | sort.Slice(scopes, func(i, j int) bool { 42 | return strings.Compare(scopes[i].Handle, scopes[j].Handle) == -1 43 | }) 44 | 45 | for _, scope := range scopes { 46 | t.AddLine(scope.Handle) 47 | } 48 | 49 | t.Print() 50 | 51 | return nil 52 | } 53 | 54 | func infoAction(c *cli.Context) error { 55 | shop, err := cmd.NewShopifyClient(c).Shop.Get(nil) 56 | if err != nil { 57 | return fmt.Errorf("Cannot get info for shop: %s", err) 58 | } 59 | 60 | t := tabby.New() 61 | s := reflect.ValueOf(shop).Elem() 62 | 63 | for i := 0; i < s.NumField(); i++ { 64 | t.AddLine(formatField(s.Type().Field(i).Name), s.Field(i).Interface()) 65 | } 66 | 67 | t.Print() 68 | 69 | return nil 70 | } 71 | 72 | func init() { 73 | Cmd = cli.Command{ 74 | Name: "shop", 75 | Aliases: []string{"s"}, 76 | Usage: "Information about the given shop", 77 | Subcommands: []*cli.Command{ 78 | { 79 | Name: "access", 80 | Aliases: []string{"a"}, 81 | Usage: "List access scopes granted to the shop's token", 82 | Flags: cmd.Flags, 83 | Action: accessAction, 84 | }, 85 | { 86 | Name: "info", 87 | Aliases: []string{"i"}, 88 | Usage: "Information about the shop", 89 | Flags: cmd.Flags, 90 | Action: infoAction, 91 | }, 92 | }, 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /cmd/themes/themes.go: -------------------------------------------------------------------------------- 1 | package themes 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "strings" 9 | 10 | "github.com/urfave/cli/v2" 11 | shopify "github.com/bold-commerce/go-shopify/v3" 12 | 13 | "github.com/ScreenStaring/shopify-dev-tools/cmd" 14 | ) 15 | 16 | var Cmd cli.Command 17 | 18 | func isDir(path string) bool { 19 | stat, err := os.Stat(path) 20 | return err == nil && stat.IsDir() 21 | } 22 | 23 | func destinationPath(source, destination string) string { 24 | const themePathSeperator = "/" 25 | 26 | if strings.Index(destination, ".") == -1 { 27 | if(destination[len(destination) - 1] != themePathSeperator[0]) { 28 | destination = destination + themePathSeperator 29 | } 30 | 31 | path := strings.Split(source, string(os.PathSeparator)) 32 | destination = destination + path[len(path) - 1] 33 | } 34 | 35 | return destination 36 | } 37 | 38 | func uploadFile(client *shopify.Client, themeID int64, source, destination string) error { 39 | destination = destinationPath(source, destination) 40 | 41 | fmt.Printf("Uploading '%s' to '%s'\n", source, destination) 42 | 43 | value, err := os.ReadFile(source) 44 | if err != nil { 45 | return fmt.Errorf("Failed to read file '%s': %s", source, err) 46 | } 47 | 48 | asset := shopify.Asset{Key: destination, ThemeID: themeID} 49 | 50 | contentType := http.DetectContentType(value) 51 | if strings.HasPrefix(contentType, "image") || strings.HasPrefix(contentType, "video") || contentType == "application/octet-stream" { 52 | asset.Attachment = base64.StdEncoding.EncodeToString(value) 53 | } else { 54 | asset.Value = string(value) 55 | } 56 | 57 | _, err = client.Asset.Update(themeID, asset) 58 | if err != nil { 59 | return fmt.Errorf("Cannot upload asset '%s': %s", source, err) 60 | } 61 | 62 | return nil 63 | } 64 | 65 | func uploadDirectory(client *shopify.Client, themeID int64, source, destination string) error { 66 | directory, err := os.Open(source) 67 | if err != nil { 68 | return fmt.Errorf("Failed to open directory '%s': %s", directory, err) 69 | } 70 | 71 | defer directory.Close() 72 | 73 | files, err := directory.Readdir(0) 74 | if err != nil { 75 | return fmt.Errorf("Failed to read directory '%s': %s", directory, err) 76 | } 77 | 78 | for _, file := range(files) { 79 | if !file.IsDir() { 80 | path := []string{source, file.Name()} 81 | 82 | err = uploadFile(client, themeID, strings.Join(path, string(os.PathSeparator)), destination) 83 | if err != nil { 84 | return err 85 | } 86 | } 87 | } 88 | 89 | return nil 90 | } 91 | 92 | func copyAction(c *cli.Context) error { 93 | if c.NArg() < 1 { 94 | return fmt.Errorf("You must supply a theme id") 95 | } 96 | 97 | if c.NArg() < 3 { 98 | return fmt.Errorf("You must supply a source and destination") 99 | } 100 | 101 | 102 | themeID, err := cmd.ParseIntAt(c, 0) 103 | if err != nil { 104 | return fmt.Errorf("Theme id '%s' invalid: must be an int", c.Args().Get(0)) 105 | } 106 | 107 | client := cmd.NewShopifyClient(c) 108 | 109 | args := c.Args().Slice() 110 | sources := args[1:len(args) - 1] 111 | destination := args[len(args) - 1] 112 | 113 | for _, source := range(sources) { 114 | if isDir(source) { 115 | err = uploadDirectory(client, themeID, source, destination) 116 | } else { 117 | err = uploadFile(client, themeID, source, destination) 118 | } 119 | 120 | if err != nil { 121 | return err 122 | } 123 | } 124 | 125 | return nil 126 | } 127 | 128 | func init() { 129 | Cmd = cli.Command{ 130 | Name: "themes", 131 | Aliases: []string{"theme", "t"}, 132 | Usage: "Theme utilities", 133 | Subcommands: []*cli.Command{ 134 | { 135 | Name: "cp", 136 | Aliases: []string{"copy"}, 137 | Usage: "Copy files to a theme", 138 | ArgsUsage: "themeid source [...] destination", 139 | Flags: cmd.Flags, 140 | Action: copyAction, 141 | }, 142 | }, 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /cmd/webhooks/webhooks.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/urfave/cli/v2" 11 | shopify "github.com/bold-commerce/go-shopify/v3" 12 | "github.com/cheynewallace/tabby" 13 | 14 | "github.com/ScreenStaring/shopify-dev-tools/cmd" 15 | ) 16 | 17 | type webhookOptions struct { 18 | // sort.. 19 | JSONL bool 20 | } 21 | 22 | type listWebhookOptions struct { 23 | Topic string `url:"topic"` 24 | } 25 | 26 | var Cmd cli.Command 27 | var webhookName = regexp.MustCompile(`(?i)\A[_a-zA-Z]+/[_a-zA-Z]+\z`) 28 | 29 | func format(c *cli.Context) string { 30 | if c.Bool("xml") { 31 | return "xml" 32 | } 33 | 34 | return "json" 35 | } 36 | 37 | func printFormatted(webhooks []shopify.Webhook) { 38 | t := tabby.New() 39 | for _, webhook := range webhooks { 40 | t.AddLine("Id", webhook.ID) 41 | t.AddLine("Address", webhook.Address) 42 | t.AddLine("Topic", webhook.Topic) 43 | t.AddLine("Fields", webhook.Fields) 44 | // Not in shopify-go: 45 | //t.AddLine("api version", webhook.APIVersion) 46 | // --- 47 | // webhook.MetafieldNamespaces 48 | t.AddLine("Created", webhook.CreatedAt) 49 | t.AddLine("Updated", webhook.UpdatedAt) 50 | t.Print() 51 | 52 | fmt.Printf("%s\n", strings.Repeat("-", 20)) 53 | } 54 | } 55 | 56 | func printJSONL(webhooks []shopify.Webhook) { 57 | for _, webhook := range webhooks { 58 | line, err := json.Marshal(webhook) 59 | if err != nil { 60 | panic(err) 61 | } 62 | 63 | fmt.Println(string(line)) 64 | } 65 | } 66 | 67 | func findAllWebhooks(client *shopify.Client) ([]int64, error) { 68 | var hookIDs []int64 69 | 70 | // FIXME: pagination 71 | webhooks, err := client.Webhook.List(nil) 72 | if err != nil { 73 | return []int64{}, fmt.Errorf("Cannot list webhooks: %s", err) 74 | } 75 | 76 | for _, webhook := range webhooks { 77 | hookIDs = append(hookIDs, webhook.ID) 78 | } 79 | 80 | return hookIDs, nil 81 | } 82 | 83 | func findGivenWebhooks(client *shopify.Client, wanted []string) ([]int64, error) { 84 | var hookIDs []int64 85 | 86 | for _, arg := range wanted { 87 | if webhookName.MatchString(arg) { 88 | options := listWebhookOptions{Topic: arg} 89 | webhooks, err := client.Webhook.List(options) 90 | if err != nil { 91 | return []int64{}, fmt.Errorf("Cannot list webhooks for topic %s: %s", options.Topic, err) 92 | } 93 | 94 | for _, webhook := range webhooks { 95 | hookIDs = append(hookIDs, webhook.ID) 96 | } 97 | } else { 98 | id, err := strconv.ParseInt(arg, 10, 64) 99 | if err != nil { 100 | return []int64{}, fmt.Errorf("Webhook id '%s' is invalid: must be an int", arg) 101 | } 102 | 103 | hookIDs = append(hookIDs, id) 104 | } 105 | } 106 | 107 | return hookIDs, nil 108 | } 109 | 110 | func createAction(c *cli.Context) error { 111 | options := shopify.Webhook{ 112 | Address: c.String("address"), 113 | Topic: c.String("topic"), 114 | Fields: c.StringSlice("fields"), 115 | Format: format(c), 116 | // Not supported bu bold! 117 | //ApiVersion: c.String("api-version"), 118 | } 119 | 120 | hook, err := cmd.NewShopifyClient(c).Webhook.Create(options) 121 | if err != nil { 122 | return fmt.Errorf("Cannot create webhook: %s", err) 123 | } 124 | 125 | fmt.Printf("Webhook created: %d\n", hook.ID) 126 | return nil 127 | } 128 | 129 | func deleteAction(c *cli.Context) error { 130 | var err error 131 | var hookIDs []int64 132 | 133 | client := cmd.NewShopifyClient(c) 134 | 135 | if(c.Bool("all")) { 136 | hookIDs, err = findAllWebhooks(client) 137 | } else { 138 | if(c.Args().Len() == 0) { 139 | return fmt.Errorf("You must supply a webhook id or topic") 140 | } 141 | 142 | hookIDs, err = findGivenWebhooks(client, c.Args().Slice()) 143 | } 144 | 145 | if err != nil { 146 | return err 147 | } 148 | 149 | if len(hookIDs) == 0 { 150 | return fmt.Errorf("No webhooks found") 151 | } 152 | 153 | for _, id := range hookIDs { 154 | err = client.Webhook.Delete(id) 155 | if err != nil { 156 | return fmt.Errorf("Cannot delete webhook %d: %s", id, err) 157 | } 158 | } 159 | 160 | fmt.Printf("%d webhook(s) deleted\n", len(hookIDs)) 161 | 162 | return nil 163 | } 164 | 165 | func listAction(c *cli.Context) error { 166 | hooks, err := cmd.NewShopifyClient(c).Webhook.List(nil) 167 | if err != nil { 168 | return fmt.Errorf("Cannot list webhooks: %s", err) 169 | } 170 | 171 | if c.Bool("jsonl") { 172 | printJSONL(hooks) 173 | } else { 174 | printFormatted(hooks) 175 | } 176 | 177 | return nil 178 | } 179 | 180 | func updateAction(c *cli.Context) error { 181 | if(c.Args().Len() == 0) { 182 | return fmt.Errorf("You must supply a webhook id to update") 183 | } 184 | 185 | id, err := strconv.ParseInt(c.Args().Get(0), 10, 64) 186 | if err != nil { 187 | return fmt.Errorf("Webhook id '%s' is invalid: must be an int", c.Args().Get(0)) 188 | } 189 | 190 | options := shopify.Webhook{ 191 | ID: id, 192 | Address: c.String("address"), 193 | Topic: c.String("topic"), 194 | Fields: c.StringSlice("fields"), 195 | Format: format(c), 196 | } 197 | 198 | _, err = cmd.NewShopifyClient(c).Webhook.Update(options) 199 | if err != nil { 200 | return fmt.Errorf("Cannot update webhook: %s", err) 201 | } 202 | 203 | fmt.Println("Webhook updated") 204 | return nil 205 | } 206 | 207 | func init() { 208 | createFlags := []cli.Flag{ 209 | &cli.StringFlag{ 210 | Name: "address", 211 | Required: true, 212 | Aliases: []string{"a"}, 213 | }, 214 | &cli.StringSliceFlag{ 215 | Name: "fields", 216 | Aliases: []string{"f"}, 217 | }, 218 | &cli.BoolFlag{ 219 | Name: "xml", 220 | Value: false, 221 | }, 222 | &cli.StringFlag{ 223 | Name: "topic", 224 | Required: true, 225 | Aliases: []string{"t"}, 226 | }, 227 | } 228 | 229 | deleteFlags := []cli.Flag{ 230 | &cli.BoolFlag{ 231 | Name: "all", 232 | Aliases: []string{"a"}, 233 | }, 234 | } 235 | 236 | listFlags := []cli.Flag{ 237 | &cli.BoolFlag{ 238 | Name: "jsonl", 239 | Aliases: []string{"j"}, 240 | }, 241 | } 242 | 243 | Cmd = cli.Command{ 244 | Name: "webhook", 245 | Aliases: []string{"webhooks", "hooks", "w"}, 246 | Usage: "Webhook utilities", 247 | Subcommands: []*cli.Command{ 248 | { 249 | Name: "create", 250 | Aliases: []string{"c"}, 251 | Flags: append(cmd.Flags, createFlags...), 252 | Action: createAction, 253 | Usage: "Create a webhook for the given shop", 254 | }, 255 | { 256 | Name: "delete", 257 | ArgsUsage: "[topic or webhook ID]", 258 | Aliases: []string{"del", "rm", "d"}, 259 | Flags: append(cmd.Flags, deleteFlags...), 260 | Action: deleteAction, 261 | Usage: "Delete the given webhook", 262 | }, 263 | { 264 | Name: "ls", 265 | Flags: append(cmd.Flags, listFlags...), 266 | Action: listAction, 267 | Usage: "List the shop's webhooks", 268 | }, 269 | // { 270 | // Name: "update", 271 | // Aliases: []string{"u"}, 272 | // // No! FLags here are optional! 273 | // Flags: append(cmd.Flags, createFlags...), 274 | // Action: updateAction, 275 | // Usage: "Update the given webhook", 276 | // }, 277 | }, 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ScreenStaring/shopify-dev-tools 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/bold-commerce/go-shopify v2.3.0+incompatible // indirect 7 | github.com/bold-commerce/go-shopify/v3 v3.14.0 8 | github.com/cheynewallace/tabby v1.1.1 9 | github.com/clbanning/mxj v1.8.4 10 | github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect 11 | github.com/google/go-querystring v1.1.0 // indirect 12 | github.com/pkg/browser v0.0.0-20201207095918-0426ae3fba23 13 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 14 | github.com/shopspring/decimal v1.3.1 // indirect 15 | github.com/urfave/cli/v2 v2.3.0 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/bold-commerce/go-shopify v2.3.0+incompatible h1:AiedLiOoFWp7iVO8n6JJOM7IEdyU4nLAvUKNM7Hw8b4= 4 | github.com/bold-commerce/go-shopify v2.3.0+incompatible/go.mod h1:R9OKSw+EViwy7MGrAxma3q+Vqq8kMyZu+OJhx/dcw6s= 5 | github.com/bold-commerce/go-shopify/v3 v3.11.0 h1:3FXbtpUmhz91kswPxrIQWFneoxOTW+RWbmhp05bISjI= 6 | github.com/bold-commerce/go-shopify/v3 v3.11.0/go.mod h1:MxKdd8wvTKrRLh19VLZsJwQy0Qw/8GO9TRol+6ErDxg= 7 | github.com/bold-commerce/go-shopify/v3 v3.12.0 h1:zB65ikoXWKOfcQPZGX+1yg4gUDuMrndiqMW53rdrBrs= 8 | github.com/bold-commerce/go-shopify/v3 v3.12.0/go.mod h1:MxKdd8wvTKrRLh19VLZsJwQy0Qw/8GO9TRol+6ErDxg= 9 | github.com/bold-commerce/go-shopify/v3 v3.14.0 h1:YHq1MegncCIOgXNVx4iuftYKe8VsCXn9QjO8O0ggubY= 10 | github.com/bold-commerce/go-shopify/v3 v3.14.0/go.mod h1:qOrEfYoy5RRO/PAq4vGyHW03NZmt2iX/fPGuaZwemtI= 11 | github.com/cheynewallace/tabby v1.1.1 h1:JvUR8waht4Y0S3JF17G6Vhyt+FRhnqVCkk8l4YrOU54= 12 | github.com/cheynewallace/tabby v1.1.1/go.mod h1:Pba/6cUL8uYqvOc9RkyvFbHGrQ9wShyrn6/S/1OYVys= 13 | github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I= 14 | github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= 15 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 16 | github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= 17 | github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 18 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= 20 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 21 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 22 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 23 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 24 | github.com/jarcoal/httpmock v1.0.4 h1:jp+dy/+nonJE4g4xbVtl9QdrUNbn6/3hDT5R4nDIZnA= 25 | github.com/jarcoal/httpmock v1.0.4/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= 26 | github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= 27 | github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= 28 | github.com/pkg/browser v0.0.0-20201207095918-0426ae3fba23 h1:dofHuld+js7eKSemxqTVIo8yRlpRw+H1SdpzZxWruBc= 29 | github.com/pkg/browser v0.0.0-20201207095918-0426ae3fba23/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ= 30 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 33 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 34 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 35 | github.com/shopspring/decimal v0.0.0-20200105231215-408a2507e114/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 36 | github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= 37 | github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 38 | github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= 39 | github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 40 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 41 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 42 | github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= 43 | github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= 44 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 45 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 46 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 47 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 48 | gopkg.in/yaml.v2 v2.2.3 h1:fvjTMHxHEw/mxHbtzPi3JCcKXQRAnQTBRo6YCJSVHKI= 49 | gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 50 | -------------------------------------------------------------------------------- /gql/client.go: -------------------------------------------------------------------------------- 1 | package gql 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "strings" 9 | 10 | _ "github.com/cheynewallace/tabby" 11 | 12 | "github.com/clbanning/mxj" 13 | ) 14 | 15 | type Client struct { 16 | endpoint string 17 | token string 18 | } 19 | 20 | // We omit the "/" after API for the case where there's no version. 21 | const endpoint = "https://%s.myshopify.com/admin/api%s/graphql.json" 22 | 23 | func NewClient(shop, token, version string) *Client { 24 | if len(version) > 0 { 25 | version = "/" + version 26 | } 27 | 28 | // allow for NAME.myshopify.com or just NAME 29 | shop = strings.SplitN(shop, ".", 2)[0] 30 | 31 | return &Client{endpoint: fmt.Sprintf(endpoint, shop, version), token: token} 32 | } 33 | 34 | func (c *Client) Query(q string) (mxj.Map, error) { 35 | return c.request(q, nil) 36 | } 37 | 38 | func (c *Client) Mutation(q string, variables map[string]interface{}) (mxj.Map, error) { 39 | return c.request(q, variables) 40 | } 41 | 42 | func (c *Client) request(gql string, variables map[string]interface{}) (mxj.Map, error) { 43 | var result mxj.Map 44 | 45 | body, err := c.createRequestBody(gql, variables) 46 | if err != nil { 47 | return result, fmt.Errorf("Failed to marshal GraphQL request body: %s", err) 48 | } 49 | 50 | client := http.Client{} 51 | 52 | req, err := http.NewRequest("POST", c.endpoint, strings.NewReader(string(body))) 53 | if err != nil { 54 | return result, fmt.Errorf("Failed to make GraphQL request: %s", c.endpoint, err) 55 | } 56 | 57 | req.Header.Add("Content-Type", "application/json") 58 | req.Header.Add("X-Shopify-Access-Token", c.token) 59 | 60 | resp, err := client.Do(req) 61 | if err != nil { 62 | return result, fmt.Errorf("GraphQL request failed: %s", c.endpoint, err) 63 | } 64 | 65 | defer resp.Body.Close() 66 | bytes, err := ioutil.ReadAll(resp.Body) 67 | 68 | // results in parse error 69 | //result, err = mxj.NewMapJsonReader(resp.Body) 70 | 71 | result, err = mxj.NewMapJson(bytes) 72 | if err != nil { 73 | return result, fmt.Errorf("Failed to unmarshal GraphQL response body: %s", err) 74 | } 75 | 76 | return result, nil 77 | } 78 | 79 | func (c *Client) createRequestBody(query string, variables map[string]interface{}) (string, error) { 80 | params := map[string]interface{}{"query": query} 81 | 82 | if len(variables) > 0 { 83 | params["variables"] = variables 84 | } 85 | 86 | body, err := json.Marshal(params) 87 | if err != nil { 88 | return "", err 89 | } 90 | 91 | return string(body), nil 92 | } 93 | -------------------------------------------------------------------------------- /gql/storefront/storefront.go: -------------------------------------------------------------------------------- 1 | package storefront 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "github.com/ScreenStaring/shopify-dev-tools/gql" 7 | ) 8 | 9 | type Storefront struct { 10 | client *gql.Client 11 | } 12 | 13 | const listQuery = ` 14 | { 15 | metafieldStorefrontVisibilities(first: 250) { 16 | pageInfo { 17 | hasNextPage 18 | } 19 | edges { 20 | cursor 21 | node { 22 | id 23 | key 24 | namespace 25 | createdAt 26 | updatedAt 27 | legacyResourceId 28 | namespace 29 | ownerType 30 | } 31 | } 32 | } 33 | } 34 | ` 35 | 36 | const enableMutation = ` 37 | mutation Enable($namespace: String! $key: String! $owner: MetafieldOwnerType!) { 38 | metafieldStorefrontVisibilityCreate( 39 | input: { 40 | namespace: $namespace 41 | key: $key 42 | ownerType: $owner 43 | } 44 | ) { 45 | metafieldStorefrontVisibility { 46 | id 47 | } 48 | userErrors { 49 | field 50 | message 51 | } 52 | } 53 | } 54 | ` 55 | 56 | func New(shop, token string) *Storefront { 57 | client := gql.NewClient(shop, token, "") 58 | return &Storefront{client} 59 | } 60 | 61 | 62 | func stringifyUserErrors(errors []interface{}) string { 63 | var userError []string 64 | 65 | for _, error := range errors { 66 | error := error.(map[string]interface{}) 67 | userError = append(userError, fmt.Sprint(error["message"])) 68 | } 69 | 70 | return strings.Join(userError, ", ") 71 | } 72 | 73 | // no pagination... 74 | func (sf *Storefront) List() ([]map[string]interface{}, error) { 75 | var result []map[string]interface{} 76 | 77 | data, err := sf.client.Query(listQuery) 78 | if err != nil { 79 | return result, fmt.Errorf("Failed to retrieve storefront metafields: %s", err) 80 | } 81 | 82 | nodes, err := data.ValuesForPath("data.metafieldStorefrontVisibilities.edges.node") 83 | if err != nil { 84 | return result, fmt.Errorf("Failed to extract storefront metafields from response: %s", err) 85 | } 86 | 87 | for _, node := range nodes { 88 | result = append(result, node.(map[string]interface{})) 89 | } 90 | 91 | return result, nil 92 | } 93 | 94 | func (sf *Storefront) Enable(name, owner string) (string, error) { 95 | var result string 96 | 97 | key := strings.SplitN(name, ".", 2) 98 | if len(key) < 2 { 99 | return result, fmt.Errorf("Metafield key %s invalid: must be in namespace.key format", name) 100 | } 101 | 102 | data, err := sf.client.Mutation(enableMutation, map[string]interface{}{"namespace": key[0], "key": key[1], "owner": strings.ToUpper(owner)}) 103 | if err != nil { 104 | return result, fmt.Errorf("Failed to enable storefront metafield %s: %s", name, err) 105 | } 106 | 107 | // TODO: collect 'em all! 108 | message, _ := data.ValueForPathString("errors[0].message") 109 | if len(message) > 0 { 110 | return result, fmt.Errorf("Request failed: %s", message) 111 | } 112 | 113 | messages, _ := data.ValuesForPath("data.metafieldStorefrontVisibilityCreate.userErrors") 114 | if len(messages) > 0 { 115 | return result, fmt.Errorf("Request failed: %s", stringifyUserErrors(messages)) 116 | } 117 | 118 | result, err = data.ValueForPathString("data.metafieldStorefrontVisibilityCreate.metafieldStorefrontVisibility.id") 119 | if err != nil { 120 | return result, fmt.Errorf("Failed to extract storefront metafield visibility id from response: %s", err) 121 | } 122 | 123 | return result, nil 124 | } 125 | -------------------------------------------------------------------------------- /sdt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/ScreenStaring/shopify-dev-tools/cmd/admin" 8 | "github.com/ScreenStaring/shopify-dev-tools/cmd/charges" 9 | "github.com/ScreenStaring/shopify-dev-tools/cmd/gql" 10 | "github.com/ScreenStaring/shopify-dev-tools/cmd/metafields" 11 | "github.com/ScreenStaring/shopify-dev-tools/cmd/orders" 12 | "github.com/ScreenStaring/shopify-dev-tools/cmd/products" 13 | "github.com/ScreenStaring/shopify-dev-tools/cmd/scripttags" 14 | "github.com/ScreenStaring/shopify-dev-tools/cmd/shop" 15 | "github.com/ScreenStaring/shopify-dev-tools/cmd/themes" 16 | "github.com/ScreenStaring/shopify-dev-tools/cmd/webhooks" 17 | "github.com/urfave/cli/v2" 18 | ) 19 | 20 | const version = "0.0.5" 21 | 22 | func main() { 23 | app := &cli.App{ 24 | Name: "sdt", 25 | Usage: "Shopify Development Tools", 26 | Version: version, 27 | UseShortOptionHandling: true, 28 | Commands: []*cli.Command{ 29 | &admin.Cmd, 30 | &charges.Cmd, 31 | &metafields.Cmd, 32 | &orders.Cmd, 33 | &products.Cmd, 34 | &gql.Cmd, 35 | &shop.Cmd, 36 | &scripttags.Cmd, 37 | &themes.Cmd, 38 | &webhooks.Cmd, 39 | }, 40 | } 41 | 42 | err := app.Run(os.Args) 43 | if err != nil { 44 | fmt.Fprintln(os.Stderr, err.Error()) 45 | } 46 | } 47 | --------------------------------------------------------------------------------