diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a20f5d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/go +# Edit at https://www.toptal.com/developers/gitignore?templates=go + +### Go ### +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +### Go Patch ### +/vendor/ +/Godeps/ + +# End of https://www.toptal.com/developers/gitignore/api/go diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..b9b9be0 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,13 @@ +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + +snapshot: + name_template: "{{ .Tag }}-next" + +changelog: + skip: true diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a13ed5 --- /dev/null +++ b/README.md @@ -0,0 +1,187 @@ +# Shopify Development Tools + +Command-line program to assist with the development and/or maintenance of Shopify apps and stores. + +## Installation + +Download the version for your platform on the [releases page](https://github.com/ScreenStaring/shopify_dev_tools/releases). +Windows, macOS/OS X, and GNU/Linux are supported. + +## Usage + + NAME: + sdt - Shopify Development Tools + + USAGE: + sdt command [command options] [arguments...] + + VERSION: + 0.0.1 + + COMMANDS: + admin, a Open admin pages + metafield, m, meta Metafield utilities + shop, s Information about the given shop + webhook, webhooks, hooks, w Webhook utilities + help, h Shows a list of commands or help for one command + + GLOBAL OPTIONS: + --help, -h show help (default: false) + --version, -v print the version (default: false) + +### Credentials + +You'll need access to the Shopify store you want to execute commands against. + +#### Access Token + +If the store has your app installed you can use the credentials generated when the shop installed your app: +``` +sdt COMMAND --shop shopname --access-token value +``` + +In this scenario you will likely need to execute the command against many shops, and having to lookup the token every +time you need it can become annoying. To simplify this process you can [specify an Access Token Command](#access-token-command). + +#### Key & Password + +If you have access to the store via the Shopify Admin you can authenticate by +[generating private app API credentials](https://shopify.dev/tutorials/generate-api-credentials). Once obtained they can be specified as follows: +``` +sdt COMMAND --shop shopname --api-key thekey --api-password thepassword +``` + +#### Access Token Command + +Instead of specifying an access token per store you can provide a custom command that can lookup the token for the given `shop`. +For example: + +``` +sdt COMMAND --shop shopname --access-token ' ARGV[0]).token" "$shop"' +``` + +Furthermore, you can use the [`SHOPIFY_ACCESS_TOKEN` environment variable](#environment-variables) to reduce the required options to +just `shop`: + +``` +export SHOPIFY_ACCESS_TOKEN=' 0 { + qs := url.Values{} + + for k, v := range(q) { + qs.Set(k, v) + } + + s += "?"+qs.Encode() + } + + return s +} + +func (a *Admin) Order(id int64, q map[string]string) string { + return a.buildURL(fmt.Sprintf(order, id), q) +} + +func (a *Admin) Orders(q map[string]string) string { + return a.buildURL(orders, q) +} + +func (a *Admin) Product(id int64, q map[string]string) string { + return a.buildURL(fmt.Sprintf(product, id), q) +} + +func (a *Admin) Products(q map[string]string) string { + return a.buildURL(products, q) +} + +func (a *Admin) Theme(id int64, q map[string]string) string { + return a.buildURL(fmt.Sprintf(theme, id), q) +} + +func (a *Admin) Themes(q map[string]string) string { + return a.buildURL(themes, q) +} diff --git a/cmd/cmd.go b/cmd/cmd.go new file mode 100644 index 0000000..10ea3d5 --- /dev/null +++ b/cmd/cmd.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + + "github.com/urfave/cli/v2" + "github.com/urfave/cli/v2/altsrc" + shopify "github.com/bold-commerce/go-shopify/v3" +) + +var Flags []cli.Flag +var accessTokenCommand = regexp.MustCompile(`\A\s*<\s*(.+)\z`) + +func NewShopifyClient(c *cli.Context) *shopify.Client { + app := shopify.App{ + ApiKey: c.String("api-key"), + Password: c.String("api-password"), + } + + //logger := &shopify.LeveledLogger{Level: shopify.LevelDebug} + //return shopify.NewClient(app, c.String("shop"), c.String("access-token"), shopify.WithLogger(logger)) + + shop := c.String("shop") + return shopify.NewClient(app, shop, lookupAccessToken(shop, c.String("access-token"))) +} + +func ParseIntAt(c *cli.Context, pos int) (int64, error) { + return strconv.ParseInt(c.Args().Get(pos), 10, 64) +} + +func lookupAccessToken(shop, token string) string { + match := accessTokenCommand.FindStringSubmatch(token) + if len(match) == 0 { + return token + } + + out, err := exec.Command(match[1], shop).Output() + if err != nil { + fmt.Fprintf(os.Stderr, "access token command failed: %s\n", err) + os.Exit(2) + } + + return strings.TrimSuffix(string(out), "\n") +} + +func init() { + Flags = []cli.Flag{ + altsrc.NewStringFlag( + &cli.StringFlag{ + Name: "shop", + Usage: "Shopify domain or shop name to perform command against", + Required: true, + EnvVars: []string{"SHOPIFY_SHOP"}, + }, + ), + &cli.StringFlag{ + Name: "api-password", + Usage: "Shopify API password", + EnvVars: []string{"SHOPIFY_API_PASSWORD"}, + }, + &cli.StringFlag{ + Name: "access-token", + Usage: "Shopify access token for shop", + EnvVars: []string{"SHOPIFY_ACCESS_TOKEN"}, + }, + &cli.StringFlag{ + Name: "api-key", + Usage: "Shopify API key to for shop", + EnvVars: []string{"SHOPIFY_API_KEY"}, + }, + } +} diff --git a/cmd/metafields/metafields.go b/cmd/metafields/metafields.go new file mode 100644 index 0000000..f05dfdb --- /dev/null +++ b/cmd/metafields/metafields.go @@ -0,0 +1,267 @@ +package metafields + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/urfave/cli/v2" + shopify "github.com/bold-commerce/go-shopify/v3" + "github.com/cheynewallace/tabby" + + "github.com/ScreenStaring/shopify-dev-tools/cmd" + "github.com/ScreenStaring/shopify-dev-tools/gql/storefront" +) + +type metafieldOptions struct { + Namespace string `url:"namespace"` + Key string `url:"key"` + JSONL bool + OrderBy []string +} + +var Cmd cli.Command + +var sortByFieldFuncs = map[string]lessFunc{ + "namespace": byNamespaceAsc, + "namespace:asc": byNamespaceAsc, + "namespace:desc": byNamespaceDesc, + "key": byKeyAsc, + "key:asc": byKeyAsc, + "key:desc": byKeyDesc, + "create": byCreatedAtAsc, + "create:asc": byCreatedAtAsc, + "create:desc": byCreatedAtDesc, + "created": byCreatedAtAsc, + "created:asc": byCreatedAtAsc, + "created:desc": byCreatedAtDesc, + "update": byUpdatedAtAsc, + "update:asc": byUpdatedAtAsc, + "update:desc": byUpdatedAtDesc, + "updated": byUpdatedAtAsc, + "updated:asc": byUpdatedAtAsc, + "updated:desc": byUpdatedAtDesc, +} + +func contextToOptions(c *cli.Context) metafieldOptions { + return metafieldOptions { + Key: c.String("key"), + Namespace: c.String("namespace"), + OrderBy: c.StringSlice("order"), + JSONL: c.Bool("jsonl"), + } +} + +func printMetafields(metafields []shopify.Metafield, options metafieldOptions) { + if options.JSONL { + printJSONL(metafields) + } else { + printFormatted(metafields, options) + } +} + +func printJSONL(metafields []shopify.Metafield) { + for _, metafield := range metafields { + line, err := json.Marshal(metafield) + if err != nil { + panic(err) + } + + fmt.Println(string(line)) + } + +} + +func printFormatted(metafields []shopify.Metafield, options metafieldOptions) { + sortMetafields(metafields, options) + + t := tabby.New() + for _, metafield := range metafields { + t.AddLine("Id", metafield.ID) + t.AddLine("Gid", metafield.AdminGraphqlAPIID) + t.AddLine("Namespace", metafield.Namespace) + t.AddLine("Key", metafield.Key) + t.AddLine("Description", metafield.Description) + // format JSON strings + // also check for string types that look like json: /\A\{"[^"]+":/ or /\A[/ and /\]\Z/ + t.AddLine("Value", metafield.Value) + t.AddLine("Type", metafield.ValueType) + t.AddLine("Created", metafield.CreatedAt) + t.AddLine("Updated", metafield.UpdatedAt) + t.Print() + fmt.Printf("%s\n", strings.Repeat("-", 20)) + } +} + +// Cannot sort storefront metafields from GQL +func sortMetafields(metafields []shopify.Metafield, options metafieldOptions) { + var funcs []lessFunc + + if len(options.OrderBy) != 0 { + for _, field := range(options.OrderBy) { + funcs = append(funcs, sortByFieldFuncs[field]) + } + } else { + if options.Namespace != "" { + funcs = []lessFunc{byKeyAsc} + } else if options.Key != "" { + funcs = []lessFunc{byNamespaceAsc} + } else { + funcs = []lessFunc{byNamespaceAsc, byKeyAsc} + } + } + + sorter := metafieldsSorter{less: funcs} + sorter.Sort(metafields) +} + +func productAction(c *cli.Context) error { + if c.NArg() == 0 { + return errors.New("Product id required") + } + + id, err := strconv.ParseInt(c.Args().Get(0), 10, 64) + if err != nil { + return fmt.Errorf("Product id '%s' invalid: must be an int", c.Args().Get(0)) + } + + options := contextToOptions(c) + metafields, err := cmd.NewShopifyClient(c).Product.ListMetafields(id, options) + if err != nil { + return fmt.Errorf("Cannot list metafields for product %d: %s", id, err) + } + + printFormatted(metafields, options) + return nil +} + +func shopAction(c *cli.Context) error { + options := contextToOptions(c) + metafields, err := cmd.NewShopifyClient(c).Metafield.List(options) + if err != nil { + return fmt.Errorf("Cannot list metafields for shop: %s", err) + } + + printFormatted(metafields, options) + + return nil +} + +func storefrontAction(c *cli.Context) error { + metafields, err := storefront.New(c.String("shop"), c.String("access-token")).List() + if err != nil { + return err + } + + //fmt.Printf("%+v\n", metafields) + + t := tabby.New() + for _, metafield := range metafields { + t.AddLine("Id", metafield["legacyResourceId"]) + t.AddLine("Gid", metafield["id"]) + t.AddLine("Namespace", metafield["namespace"]) + t.AddLine("Key", metafield["key"]) + t.AddLine("Owner Type", metafield["ownerType"]) + t.AddLine("Created", metafield["createdAt"]) + t.AddLine("Updated", metafield["updatedAt"]) + t.Print() + fmt.Printf("%s\n", strings.Repeat("-", 20)) + } + + return nil +} + +func variantAction(c *cli.Context) error { + if c.NArg() == 0 { + return errors.New("Variant id required") + } + + id, err := strconv.ParseInt(c.Args().Get(0), 10, 64) + if err != nil { + return fmt.Errorf("Variant id '%s' invalid: must be an int", c.Args().Get(0)) + } + + options := contextToOptions(c) + metafields, err := cmd.NewShopifyClient(c).Variant.ListMetafields(id, options) + if err != nil { + return fmt.Errorf("Cannot list metafields for variant %d: %s", id, err) + } + + printFormatted(metafields, options) + + return nil +} + +func init() { + metafieldFlags := []cli.Flag{ + &cli.StringFlag{ + Name: "key", + Aliases: []string{"k"}, + Usage: "Find metafields with the given key", + }, + &cli.StringFlag{ + Name: "namespace", + Aliases: []string{"n"}, + Usage: "Find metafields with the given namespace", + }, + &cli.StringSliceFlag{ + Name: "order", + Aliases: []string{"o"}, + Usage: "Order metafields by the given properties", + }, + &cli.BoolFlag{ + Name: "jsonl", + Aliases: []string{"j"}, + Usage: "Output the metafields in JSONL format", + }, + } + + // create! + Cmd = cli.Command{ + Name: "metafield", + Aliases: []string{"m", "meta"}, + Usage: "Metafield utilities", + Subcommands: []*cli.Command{ + { + Name: "product", + Flags: append(cmd.Flags, metafieldFlags...), + Aliases: []string{"products", "prod", "p"}, + Action: productAction, + }, + { + Name: "shop", + Flags: append(cmd.Flags, metafieldFlags...), + Aliases: []string{"s"}, + Action: shopAction, + }, + { + Name: "storefront", + Aliases: []string{"sf"}, + Usage: "Storefront API utilities", + Subcommands: []*cli.Command{ + { + Name: "ls", + Flags: append(cmd.Flags, metafieldFlags...), + Action: storefrontAction, + }, + // { + // // --key, --namespace --owner + // Name: "create", + // Flags: append(cmd.Flags, metafieldFlags...), + // Action: storefrontAction, + // }, + }, + + }, + { + Name: "variant", + Aliases: []string{"var", "v"}, + Flags: append(cmd.Flags, metafieldFlags...), + Action: variantAction, + }, + }, + } + +} diff --git a/cmd/metafields/sorting.go b/cmd/metafields/sorting.go new file mode 100644 index 0000000..6a36348 --- /dev/null +++ b/cmd/metafields/sorting.go @@ -0,0 +1,91 @@ +package metafields + +import ( + "sort" + "strings" + shopify "github.com/bold-commerce/go-shopify/v3" +) + +type lessFunc func(mf1, mf2 *shopify.Metafield) int + +type metafieldsSorter struct { + metafields []shopify.Metafield + less []lessFunc +} + +func (ms *metafieldsSorter) Len() int { + return len(ms.metafields) +} + +func (ms *metafieldsSorter) Swap(i, j int) { + ms.metafields[i], ms.metafields[j] = ms.metafields[j], ms.metafields[i] +} + +func (mf *metafieldsSorter) Less(i, j int) bool { + less := false + for _, fx := range(mf.less) { + order := fx(&mf.metafields[i], &mf.metafields[j]) + if order == 0 { + less = false + continue + } + + less = order == -1 + break + } + + return less +} + +func (ms *metafieldsSorter) Sort(metafields []shopify.Metafield) { + ms.metafields = metafields + sort.Sort(ms) +} + +func byNamespaceAsc(mf1, mf2 *shopify.Metafield) int { + return strings.Compare(strings.ToLower(mf1.Namespace), strings.ToLower(mf2.Namespace)) +} + +func byNamespaceDesc(mf1, mf2 *shopify.Metafield) int { + return byNamespaceAsc(mf2, mf1) +} + +func byKeyAsc(mf1, mf2 *shopify.Metafield) int { + return strings.Compare(strings.ToLower(mf1.Key), strings.ToLower(mf2.Key)) +} + +func byKeyDesc(mf1, mf2 *shopify.Metafield) int { + return byKeyAsc(mf2, mf1) +} + +func byCreatedAtAsc(mf1, mf2 *shopify.Metafield) int { + if mf1.CreatedAt.Before(*mf2.CreatedAt) { + return -1 + } + + if mf1.CreatedAt.After(*mf2.CreatedAt) { + return 1 + } + + return 0 +} + +func byCreatedAtDesc(mf1, mf2 *shopify.Metafield) int { + return byUpdatedAtAsc(mf2, mf1) +} + +func byUpdatedAtAsc(mf1, mf2 *shopify.Metafield) int { + if mf1.UpdatedAt.Before(*mf2.UpdatedAt) { + return -1 + } + + if mf1.UpdatedAt.After(*mf2.UpdatedAt) { + return 1 + } + + return 0 +} + +func byUpdatedAtDesc(mf1, mf2 *shopify.Metafield) int { + return byUpdatedAtAsc(mf2, mf1) +} diff --git a/cmd/shop/shop.go b/cmd/shop/shop.go new file mode 100644 index 0000000..8570ac4 --- /dev/null +++ b/cmd/shop/shop.go @@ -0,0 +1,71 @@ +package shop + +import ( + "fmt" + "reflect" + "regexp" + "strings" + + "github.com/urfave/cli/v2" + "github.com/ScreenStaring/shopify-dev-tools/cmd" + "github.com/cheynewallace/tabby" +) + +var Cmd cli.Command + +// Some low-budget formatting +func formatField(field string) string { + re := regexp.MustCompile("([a-z])([A-Z])") + name := strings.Replace(field, "API", "Api", 1) + name = re.ReplaceAllString(name, "$1 $2") + name = strings.Replace(name, " At", "", 1) + + return name +} + +func accessAction(c *cli.Context) error { + // not supported, need to update API client + return nil +} + +func infoAction(c *cli.Context) error { + shop, err := cmd.NewShopifyClient(c).Shop.Get(nil) + if err != nil { + return fmt.Errorf("Cannot get info for shop: %s", err) + } + + t := tabby.New() + s := reflect.ValueOf(shop).Elem() + + for i := 0; i < s.NumField(); i++ { + t.AddLine(formatField(s.Type().Field(i).Name), s.Field(i).Interface()) + } + + t.Print() + + return nil +} + +func init() { + Cmd = cli.Command{ + Name: "shop", + Aliases: []string{"s"}, + Usage: "Information about the given shop", + Subcommands: []*cli.Command{ + // { + // Name: "access", + // Aliases: []string{"a"}, + // Usage: "Permissions granted to the given token/key", + // Flags: cmd.Flags, + // Action: accessAction, + // }, + { + Name: "info", + Aliases: []string{"i"}, + Usage: "Information about the shop", + Flags: cmd.Flags, + Action: infoAction, + }, + }, + } +} diff --git a/cmd/webhooks/webhooks.go b/cmd/webhooks/webhooks.go new file mode 100644 index 0000000..e2c0ef3 --- /dev/null +++ b/cmd/webhooks/webhooks.go @@ -0,0 +1,276 @@ +package webhooks + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/urfave/cli/v2" + shopify "github.com/bold-commerce/go-shopify/v3" + "github.com/cheynewallace/tabby" + + "github.com/ScreenStaring/shopify-dev-tools/cmd" +) + +type webhookOptions struct { + // sort.. + JSONL bool +} + +type listWebhookOptions struct { + Topic string `url:"topic"` +} + +var Cmd cli.Command +var webhookName = regexp.MustCompile(`(?i)\A[_a-zA-Z]+/[_a-zA-Z]+\z`) + +func format(c *cli.Context) string { + if c.Bool("xml") { + return "xml" + } + + return "json" +} + +func printFormatted(webhooks []shopify.Webhook) { + t := tabby.New() + for _, webhook := range webhooks { + t.AddLine("Id", webhook.ID) + t.AddLine("Address", webhook.Address) + t.AddLine("Topic", webhook.Topic) + t.AddLine("Fields", webhook.Fields) + // Not in shopify-go: + //t.AddLine("api version", webhook.APIVersion) + // --- + // webhook.MetafieldNamespaces + t.AddLine("Created", webhook.CreatedAt) + t.AddLine("Updated", webhook.UpdatedAt) + t.Print() + + fmt.Printf("%s\n", strings.Repeat("-", 20)) + } +} + +func printJSONL(webhooks []shopify.Webhook) { + for _, webhook := range webhooks { + line, err := json.Marshal(webhook) + if err != nil { + panic(err) + } + + fmt.Println(string(line)) + } +} + +func findAllWebhooks(client *shopify.Client) ([]int64, error) { + var hookIDs []int64 + + // FIXME: pagination + webhooks, err := client.Webhook.List(nil) + if err != nil { + return []int64{}, fmt.Errorf("Cannot list webhooks: %s", err) + } + + for _, webhook := range webhooks { + hookIDs = append(hookIDs, webhook.ID) + } + + return hookIDs, nil +} + +func findGivenWebhooks(client *shopify.Client, wanted []string) ([]int64, error) { + var hookIDs []int64 + + for _, arg := range wanted { + if webhookName.MatchString(arg) { + options := listWebhookOptions{Topic: arg} + webhooks, err := client.Webhook.List(options) + if err != nil { + return []int64{}, fmt.Errorf("Cannot list webhooks for topic %s: %s", options.Topic, err) + } + + for _, webhook := range webhooks { + hookIDs = append(hookIDs, webhook.ID) + } + } else { + id, err := strconv.ParseInt(arg, 10, 64) + if err != nil { + return []int64{}, fmt.Errorf("Webhook id '%s' is invalid: must be an int", arg) + } + + hookIDs = append(hookIDs, id) + } + } + + return hookIDs, nil +} + +func createAction(c *cli.Context) error { + options := shopify.Webhook{ + Address: c.String("address"), + Topic: c.String("topic"), + Fields: c.StringSlice("fields"), + Format: format(c), + } + + hook, err := cmd.NewShopifyClient(c).Webhook.Create(options) + if err != nil { + return fmt.Errorf("Cannot create webhook: %s", err) + } + + fmt.Printf("Webhook created: %d\n", hook.ID) + return nil +} + +func deleteAction(c *cli.Context) error { + var err error + var hookIDs []int64 + + client := cmd.NewShopifyClient(c) + + if(c.Bool("all")) { + hookIDs, err = findAllWebhooks(client) + } else { + if(c.Args().Len() == 0) { + return fmt.Errorf("You must supply a webhook id or topic") + } + + hookIDs, err = findGivenWebhooks(client, c.Args().Slice()) + } + + if err != nil { + return err + } + + if len(hookIDs) == 0 { + return fmt.Errorf("No webhooks found") + } + + for _, id := range hookIDs { + err = client.Webhook.Delete(id) + if err != nil { + return fmt.Errorf("Cannot delete webhook %d: %s", id, err) + } + } + + fmt.Printf("%d webhook(s) deleted\n", len(hookIDs)) + + return nil +} + +func listAction(c *cli.Context) error { + hooks, err := cmd.NewShopifyClient(c).Webhook.List(nil) + if err != nil { + return fmt.Errorf("Cannot list webhooks: %s", err) + } + + if c.Bool("jsonl") { + printJSONL(hooks) + } else { + printFormatted(hooks) + } + + return nil +} + +func updateAction(c *cli.Context) error { + if(c.Args().Len() == 0) { + return fmt.Errorf("You must supply a webhook id to update") + } + + id, err := strconv.ParseInt(c.Args().Get(0), 10, 64) + if err != nil { + return fmt.Errorf("Webhook id '%s' is invalid: must be an int", c.Args().Get(0)) + } + + options := shopify.Webhook{ + ID: id, + Address: c.String("address"), + Topic: c.String("topic"), + Fields: c.StringSlice("fields"), + Format: format(c), + } + + _, err = cmd.NewShopifyClient(c).Webhook.Update(options) + if err != nil { + return fmt.Errorf("Cannot update webhook: %s", err) + } + + fmt.Println("Webhook updated") + return nil +} + +func init() { + createFlags := []cli.Flag{ + &cli.StringFlag{ + Name: "address", + Required: true, + Aliases: []string{"a"}, + }, + &cli.StringSliceFlag{ + Name: "fields", + Aliases: []string{"f"}, + }, + &cli.BoolFlag{ + Name: "xml", + Value: false, + }, + &cli.StringFlag{ + Name: "topic", + Required: true, + Aliases: []string{"t"}, + }, + } + + deleteFlags := []cli.Flag{ + &cli.BoolFlag{ + Name: "all", + Aliases: []string{"a"}, + }, + } + + listFlags := []cli.Flag{ + &cli.BoolFlag{ + Name: "jsonl", + Aliases: []string{"j"}, + }, + } + + Cmd = cli.Command{ + Name: "webhook", + Aliases: []string{"webhooks", "hooks", "w"}, + Usage: "Webhook utilities", + Subcommands: []*cli.Command{ + { + Name: "create", + Aliases: []string{"c"}, + Flags: append(cmd.Flags, createFlags...), + Action: createAction, + Usage: "Create a webhook for the given shop", + }, + { + Name: "delete", + Aliases: []string{"del", "rm", "d"}, + Flags: append(cmd.Flags, deleteFlags...), + Action: deleteAction, + Usage: "Delete the given webhook", + }, + { + Name: "ls", + Flags: append(cmd.Flags, listFlags...), + Action: listAction, + Usage: "List the shop's webhooks", + }, + // { + // Name: "update", + // Aliases: []string{"u"}, + // // No! FLags here are optional! + // Flags: append(cmd.Flags, createFlags...), + // Action: updateAction, + // Usage: "Update the given webhook", + // }, + }, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..79bb685 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/ScreenStaring/shopify-dev-tools + +go 1.15 + +require ( + github.com/bold-commerce/go-shopify v2.3.0+incompatible // indirect + github.com/bold-commerce/go-shopify/v3 v3.11.0 + github.com/cheynewallace/tabby v1.1.1 + github.com/clbanning/mxj v1.8.4 + github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/pkg/browser v0.0.0-20201207095918-0426ae3fba23 + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/shopspring/decimal v1.2.0 // indirect + github.com/urfave/cli/v2 v2.3.0 +) diff --git a/gql/client.go b/gql/client.go new file mode 100644 index 0000000..217a271 --- /dev/null +++ b/gql/client.go @@ -0,0 +1,86 @@ +package gql + + +import ( + "fmt" + "net/http" + "encoding/json" + "strings" + "io/ioutil" + + _"github.com/cheynewallace/tabby" + + "github.com/clbanning/mxj" +) + + +type Client struct { + endpoint string + token string +} + +const endpoint = "https://%s.myshopify.com/admin/api/2021-07/graphql.json" + +func NewClient(shop, token string) *Client { + return &Client{endpoint: fmt.Sprintf(endpoint, shop), token: token} +} + +func (c *Client) Query(q string) (mxj.Map, error) { + return c.request(q, nil) +} + +func (c *Client) Mutation(q string, variables map[string]interface{}) (mxj.Map, error) { + return c.request(q, variables) +} + +func (c *Client) request(gql string, variables map[string]interface{}) (mxj.Map, error) { + var result mxj.Map + + body, err := c.createRequestBody(gql, variables) + if err != nil { + return result, fmt.Errorf("Failed to marshal GraphQL request body: %s", err) + } + + client := http.Client{} + + req, err := http.NewRequest("POST", c.endpoint, strings.NewReader(string(body))) + if err != nil { + return result, fmt.Errorf("Failed to make GraphQL request: %s", c.endpoint, err) + } + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("X-Shopify-Access-Token", c.token) + + resp, err := client.Do(req) + if err != nil { + return result, fmt.Errorf("GraphQL request failed: %s", c.endpoint, err) + } + + defer resp.Body.Close() + bytes, err := ioutil.ReadAll(resp.Body) + + // results in parse error + //result, err = mxj.NewMapJsonReader(resp.Body) + + result, err = mxj.NewMapJson(bytes) + if err != nil { + return result, fmt.Errorf("Failed to unmarshal GraphQL response body: %s", err) + } + + return result, nil +} + +func (c* Client) createRequestBody(query string, variables map[string]interface{}) (string, error) { + params := map[string]interface{}{"query": query} + + if len(variables) > 0 { + params["variables"] = variables + } + + body, err := json.Marshal(params) + if err != nil { + return "", err + } + + return string(body), nil +} diff --git a/gql/storefront/storefront.go b/gql/storefront/storefront.go new file mode 100644 index 0000000..0aef4c3 --- /dev/null +++ b/gql/storefront/storefront.go @@ -0,0 +1,60 @@ +package storefront + +import ( + "fmt" + "github.com/ScreenStaring/shopify-dev-tools/gql" +) + +type Storefront struct { + client *gql.Client +} + +const listQuery = ` +{ + metafieldStorefrontVisibilities(first: 250) { + pageInfo { + hasNextPage + } + edges { + cursor + node { + id + key + namespace + createdAt + updatedAt + legacyResourceId + namespace + ownerType + } + } + } +} +` + + +func New(shop, token string) *Storefront { + client := gql.NewClient(shop, token) + return &Storefront{client} +} + +// no pagination... +func (sf *Storefront) List() ([]map[string]interface{}, error) { + var result []map[string]interface{} + + data, err := sf.client.Query(listQuery) + if err != nil { + return result, fmt.Errorf("Failed to retrieve storefront metafields: %s", err) + } + + nodes, err := data.ValuesForPath("data.metafieldStorefrontVisibilities.edges.node") + if err != nil { + return result, fmt.Errorf("Failed to extract storefront metafields from response: %s", err) + } + + for _, node := range nodes { + result = append(result, node.(map[string]interface{})) + } + + return result, nil +} diff --git a/sdt.go b/sdt.go new file mode 100644 index 0000000..3d9f94e --- /dev/null +++ b/sdt.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + "os" + + "github.com/urfave/cli/v2" + "github.com/ScreenStaring/shopify-dev-tools/cmd/admin" + "github.com/ScreenStaring/shopify-dev-tools/cmd/metafields" + "github.com/ScreenStaring/shopify-dev-tools/cmd/shop" + "github.com/ScreenStaring/shopify-dev-tools/cmd/webhooks" +) + +const version = "0.0.1" + +func main() { + app := &cli.App{ + Name: "sdt", + Usage: "Shopify Development Tools", + Version: version, + UseShortOptionHandling: true, + Commands: []*cli.Command{ + &admin.Cmd, + &metafields.Cmd, + &shop.Cmd, + &webhooks.Cmd, + }, + } + + err := app.Run(os.Args) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + } +}