package main import ( "context" "encoding/json" "errors" "flag" "fmt" "io" "net/url" "strconv" "strings" ) func requireID(args []string, usage string) (string, error) { if len(args) == 0 || args[0] == "" || strings.HasPrefix(args[0], "-") { return "", errors.New(usage) } return args[0], nil } func readRequiredBody(stdin io.Reader, args []string, label string) ([]byte, error) { body, err := readInput(stdin, inputFlag(args), positionalWithoutInput(args)) if err != nil { return nil, fmt.Errorf("read %s: %w", label, err) } if len(body) == 0 { return nil, fmt.Errorf("%s requires JSON input or a file", label) } return body, nil } func cmsPatchNode(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { id, err := requireID(args, "cms nodes patch requires a node id") if err != nil { return err } body, err := readRequiredBody(stdin, args[1:], "node patch") if err != nil { return err } if !json.Valid(body) { return errors.New("node patch input is not valid JSON") } response, err := client.patchBody(ctx, client.cmsURL("/nodes/"+escapePathSegment(id)), body, "application/json") if err != nil { return err } return writeRaw(stdout, response) } func cmsDeleteNode(ctx context.Context, client *Client, args []string, stdout io.Writer) error { id, err := requireID(args, "cms nodes delete requires a node id") if err != nil { return err } fs := flag.NewFlagSet("cms nodes delete", flag.ContinueOnError) confirmed := fs.Bool("yes", false, "confirm deletion") fs.SetOutput(io.Discard) if err := fs.Parse(args[1:]); err != nil { return err } if !*confirmed { return errors.New("cms nodes delete requires --yes") } body, err := client.delete(ctx, client.cmsURL("/nodes/"+escapePathSegment(id))) if err != nil { return err } return writeRaw(stdout, body) } func cmsRetypeNode(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { id, err := requireID(args, "cms nodes retype requires a node id") if err != nil { return err } fs := flag.NewFlagSet("cms nodes retype", flag.ContinueOnError) dataType := fs.String("type", "", "new node type") input := fs.String("input", "", "complete request JSON file or - for stdin") data := fs.String("data", "", "replacement JSON data object, @file, or - for stdin") fs.SetOutput(io.Discard) if err := fs.Parse(args[1:]); err != nil { return err } body, err := readInput(stdin, *input, fs.Args()) if err != nil { return fmt.Errorf("read node retype: %w", err) } if len(body) == 0 { if *dataType == "" { return errors.New("cms nodes retype requires --type when --input is not used") } var dataObject map[string]any if *data != "" { dataBody, readErr := readJSONValue(stdin, *data) if readErr != nil { return readErr } if err := json.Unmarshal(dataBody, &dataObject); err != nil { return fmt.Errorf("--data must be a JSON object: %w", err) } } body, err = json.Marshal(map[string]any{"dataType": *dataType, "data": dataObject}) if err != nil { return err } } if !json.Valid(body) { return errors.New("node retype input is not valid JSON") } response, err := client.postBody(ctx, client.cmsURL("/nodes/"+escapePathSegment(id)+"/retype"), body, "application/json") if err != nil { return err } return writeRaw(stdout, response) } func runCMSEdges(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { if len(args) == 0 || args[0] == "help" || args[0] == "--help" { _, _ = io.WriteString(stdout, "Usage: cmsctl cms edges get \n cmsctl cms edges create [--from ID --to ID --type child --handle NAME --sort N]\n cmsctl cms edges delete --yes\n cmsctl cms edges reorder --order edge-id,...\n") return nil } switch args[0] { case "get": id, err := requireID(args[1:], "cms edges get requires a node id") if err != nil { return err } body, err := client.get(ctx, client.cmsURL("/nodes/"+escapePathSegment(id)+"/edges")) if err != nil { return err } return writeRaw(stdout, body) case "create": fs := flag.NewFlagSet("cms edges create", flag.ContinueOnError) from := fs.String("from", "", "source node id") to := fs.String("to", "", "target node id") typ := fs.String("type", "child", "edge type") handle := fs.String("handle", "", "connection handle") sort := fs.Int("sort", 0, "sibling sort position") fs.SetOutput(io.Discard) if err := fs.Parse(args[1:]); err != nil { return err } body, err := readInput(stdin, inputFlag(args[1:]), fs.Args()) if err != nil { return err } if len(body) == 0 { if *from == "" || *to == "" { return errors.New("cms edges create requires --from and --to, or --input") } body, err = json.Marshal(map[string]any{"from": *from, "to": *to, "type": *typ, "handle": *handle, "sort": *sort}) if err != nil { return err } } if !json.Valid(body) { return errors.New("edge input is not valid JSON") } response, err := client.postBody(ctx, client.cmsURL("/edges"), body, "application/json") if err != nil { return err } return writeRaw(stdout, response) case "delete": id, err := requireID(args[1:], "cms edges delete requires an edge id") if err != nil { return err } fs := flag.NewFlagSet("cms edges delete", flag.ContinueOnError) confirmed := fs.Bool("yes", false, "confirm deletion") fs.SetOutput(io.Discard) if err := fs.Parse(args[2:]); err != nil { return err } if !*confirmed { return errors.New("cms edges delete requires --yes") } body, err := client.delete(ctx, client.cmsURL("/edges/"+escapePathSegment(id))) if err != nil { return err } return writeRaw(stdout, body) case "reorder": parent, err := requireID(args[1:], "cms edges reorder requires a parent node id") if err != nil { return err } fs := flag.NewFlagSet("cms edges reorder", flag.ContinueOnError) order := fs.String("order", "", "comma-separated edge ids in the desired order") input := fs.String("input", "", "complete request JSON file or - for stdin") fs.SetOutput(io.Discard) if err := fs.Parse(args[2:]); err != nil { return err } body, err := readInput(stdin, *input, fs.Args()) if err != nil { return err } if len(body) == 0 { if *order == "" { return errors.New("cms edges reorder requires --order or --input") } body, err = json.Marshal(map[string]any{"parent": parent, "order": splitCSV(*order)}) if err != nil { return err } } if !json.Valid(body) { return errors.New("edge reorder input is not valid JSON") } response, err := client.postBody(ctx, client.cmsURL("/edges/reorder"), body, "application/json") if err != nil { return err } return writeRaw(stdout, response) default: return fmt.Errorf("unknown cms edges command %q", args[0]) } } func runCMSSchemas(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { if len(args) == 0 || args[0] == "list" { body, err := client.get(ctx, client.cmsURL("/schemas")) if err != nil { return err } return writeRaw(stdout, body) } typ, err := requireID(args[1:], "cms schemas requires a schema type") if err != nil { return err } switch args[0] { case "get": body, err := client.get(ctx, client.cmsURL("/schemas/"+escapePathSegment(typ))) if err != nil { return err } return writeRaw(stdout, body) case "put": body, err := readRequiredBody(stdin, args[2:], "schema") if err != nil { return err } if !json.Valid(body) { return errors.New("schema input is not valid JSON") } response, err := client.putBody(ctx, client.cmsURL("/schemas/"+escapePathSegment(typ)), body, "application/json") if err != nil { return err } return writeRaw(stdout, response) case "delete": fs := flag.NewFlagSet("cms schemas delete", flag.ContinueOnError) confirmed := fs.Bool("yes", false, "confirm deletion") fs.SetOutput(io.Discard) if err := fs.Parse(args[2:]); err != nil { return err } if !*confirmed { return errors.New("cms schemas delete requires --yes") } body, err := client.delete(ctx, client.cmsURL("/schemas/"+escapePathSegment(typ))) if err != nil { return err } return writeRaw(stdout, body) default: return fmt.Errorf("unknown cms schemas command %q", args[0]) } } func runCMSTemplates(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { if len(args) == 0 || args[0] == "list" { body, err := client.get(ctx, client.cmsURL("/templates")) if err != nil { return err } return writeRaw(stdout, body) } name, err := requireID(args[1:], "cms templates requires a template name") if err != nil { return err } switch args[0] { case "get": body, err := client.get(ctx, client.cmsURL("/templates/"+escapePathSegment(name))) if err != nil { return err } return writeRaw(stdout, body) case "put": body, err := readRequiredBody(stdin, args[2:], "template") if err != nil { return err } response, err := client.putBody(ctx, client.cmsURL("/templates/"+escapePathSegment(name)), body, "text/plain; charset=utf-8") if err != nil { return err } return writeRaw(stdout, response) case "delete": fs := flag.NewFlagSet("cms templates delete", flag.ContinueOnError) confirmed := fs.Bool("yes", false, "confirm deletion") fs.SetOutput(io.Discard) if err := fs.Parse(args[2:]); err != nil { return err } if !*confirmed { return errors.New("cms templates delete requires --yes") } body, err := client.delete(ctx, client.cmsURL("/templates/"+escapePathSegment(name))) if err != nil { return err } return writeRaw(stdout, body) default: return fmt.Errorf("unknown cms templates command %q", args[0]) } } func runCMSBlueprints(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { if len(args) == 0 || args[0] == "list" { body, err := client.get(ctx, client.cmsURL("/blueprints")) if err != nil { return err } return writeRaw(stdout, body) } if args[0] != "create" { return fmt.Errorf("unknown cms blueprints command %q", args[0]) } fs := flag.NewFlagSet("cms blueprints create", flag.ContinueOnError) blueprintID := fs.String("blueprint", "", "blueprint id") name := fs.String("name", "", "created node name") pathValue := fs.String("path", "", "created node path") input := fs.String("input", "", "complete request JSON file or - for stdin") fs.SetOutput(io.Discard) if err := fs.Parse(args[1:]); err != nil { return err } body, err := readInput(stdin, *input, fs.Args()) if err != nil { return err } if len(body) == 0 { if *blueprintID == "" { return errors.New("cms blueprints create requires --blueprint or --input") } body, err = json.Marshal(map[string]string{"blueprintId": *blueprintID, "name": *name, "path": *pathValue}) if err != nil { return err } } if !json.Valid(body) { return errors.New("blueprint input is not valid JSON") } response, err := client.postBody(ctx, client.cmsURL("/nodes/create-from-blueprint"), body, "application/json") if err != nil { return err } return writeRaw(stdout, response) } func runCMSTree(ctx context.Context, client *Client, args []string, stdout io.Writer) error { id, err := requireID(args, "cms tree requires a root node id") if err != nil { return err } fs := flag.NewFlagSet("cms tree", flag.ContinueOnError) depth := fs.Int("depth", -1, "tree depth; -1 is unlimited") fs.SetOutput(io.Discard) if err := fs.Parse(args[1:]); err != nil { return err } query := url.Values{"depth": {strconv.Itoa(*depth)}} body, err := client.get(ctx, client.cmsURL("/tree/"+escapePathSegment(id))+"?"+query.Encode()) if err != nil { return err } return writeRaw(stdout, body) } func runCMSNav(ctx context.Context, client *Client, args []string, stdout io.Writer) error { fs := flag.NewFlagSet("cms nav", flag.ContinueOnError) typ := fs.String("type", "", "navigation edge type, default tree") dataType := fs.String("data-type", "", "restrict forest to a data type") fs.SetOutput(io.Discard) if err := fs.Parse(args); err != nil { return err } pathValue := "/nav" if len(fs.Args()) > 0 { pathValue += "/" + escapePathSegment(fs.Args()[0]) } query := url.Values{} if *typ != "" { query.Set("type", *typ) } if *dataType != "" { query.Set("dataType", *dataType) } endpoint := client.cmsURL(pathValue) if len(query) > 0 { endpoint += "?" + query.Encode() } body, err := client.get(ctx, endpoint) if err != nil { return err } return writeRaw(stdout, body) } func runCMSGraph(ctx context.Context, client *Client, args []string, stdout io.Writer) error { fs := flag.NewFlagSet("cms graph", flag.ContinueOnError) root := fs.String("root", "", "root node id") budget := fs.Int("budget", 0, "maximum node count") edges := fs.String("edges", "", "comma-separated edge types") fs.SetOutput(io.Discard) if err := fs.Parse(args); err != nil { return err } query := url.Values{} if *root != "" { query.Set("root", *root) } if *budget > 0 { query.Set("budget", strconv.Itoa(*budget)) } if *edges != "" { query["edges"] = splitCSV(*edges) } endpoint := client.cmsURL("/graph") if len(query) > 0 { endpoint += "?" + query.Encode() } body, err := client.get(ctx, endpoint) if err != nil { return err } return writeRaw(stdout, body) } func runCMSPublish(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { body, err := readInput(stdin, inputFlag(args), positionalWithoutInput(args)) if err != nil { return err } if len(body) == 0 { ids := positionalWithoutInput(args) if len(ids) == 0 { return errors.New("cms publish requires root ids or --input") } body, err = json.Marshal(map[string]any{"ids": ids}) if err != nil { return err } } if !json.Valid(body) { return errors.New("publish input is not valid JSON") } response, err := client.postBody(ctx, client.cmsURL("/publish-roots"), body, "application/json") if err != nil { return err } return writeRaw(stdout, response) } func runCMSRegenerate(ctx context.Context, client *Client, args []string, stdout io.Writer) error { fs := flag.NewFlagSet("cms regenerate", flag.ContinueOnError) typ := fs.String("type", "", "only regenerate roots affected by this node type") fs.SetOutput(io.Discard) if err := fs.Parse(args); err != nil { return err } endpoint := client.cmsURL("/regenerate") if *typ != "" { endpoint += "?type=" + url.QueryEscape(*typ) } body, err := client.postBody(ctx, endpoint, nil, "") if err != nil { return err } return writeRaw(stdout, body) } func runCMSScript(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { fs := flag.NewFlagSet("cms script", flag.ContinueOnError) script := fs.String("script", "", "JavaScript source") input := fs.String("input", "", "complete request JSON file or - for stdin") fs.SetOutput(io.Discard) if err := fs.Parse(args); err != nil { return err } body, err := readInput(stdin, *input, fs.Args()) if err != nil { return err } if len(body) == 0 { if *script == "" { return errors.New("cms script requires --script or --input") } body, err = json.Marshal(map[string]string{"script": *script}) if err != nil { return err } } if !json.Valid(body) { return errors.New("script input is not valid JSON") } response, err := client.postBody(ctx, client.cmsURL("/scripts/run"), body, "application/json") if err != nil { return err } return writeRaw(stdout, response) } func runFinderFacets(ctx context.Context, client *Client, args []string, stdout io.Writer) error { fs := flag.NewFlagSet("finder facets", flag.ContinueOnError) q := fs.String("q", "", "search query") filters := fs.String("filters", "", "JSON-encoded filters") resultKey := fs.String("result-key", "", "cached search result key") fs.SetOutput(io.Discard) if err := fs.Parse(args); err != nil { return err } query := url.Values{} if *q != "" { query.Set("q", *q) } if *filters != "" { query.Set("filters", *filters) } if *resultKey != "" { query.Set("resultKey", *resultKey) } return finderGetQuery(ctx, client, "facets", query, stdout) } func runFinderConfig(ctx context.Context, client *Client, args []string, stdout io.Writer) error { fs := flag.NewFlagSet("finder config", flag.ContinueOnError) filters := fs.String("filters", "", "JSON-encoded filters") stock := fs.String("stock", "", "stock filter") fs.SetOutput(io.Discard) if err := fs.Parse(args); err != nil { return err } query := url.Values{} if *filters != "" { query.Set("filters", *filters) } if *stock != "" { query.Set("stock", *stock) } return finderGetQuery(ctx, client, "config", query, stdout) } func runFinderRelated(ctx context.Context, client *Client, endpoint string, args []string, stdout io.Writer) error { id, err := requireID(args, "finder "+endpoint+" requires an item id or SKU") if err != nil { return err } return finderSimpleGet(ctx, client, []string{id}, endpoint, stdout) } func runFinderCompatible(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { id, err := requireID(args, "finder compatible requires an item id or SKU") if err != nil { return err } fs := flag.NewFlagSet("finder compatible", flag.ContinueOnError) limit := fs.Int("limit", 60, "maximum results") input := fs.String("cart-input", "", "JSON array of cart item IDs, or - for stdin") fs.SetOutput(io.Discard) if err := fs.Parse(args[1:]); err != nil { return err } query := url.Values{"limit": {strconv.Itoa(*limit)}} endpoint := client.finderURL("/compatible/"+escapePathSegment(id)) + "?" + query.Encode() if *input == "" { body, err := client.get(ctx, endpoint) if err != nil { return err } return writeRaw(stdout, body) } body, err := readInput(stdin, *input, nil) if err != nil { return err } if !json.Valid(body) { return errors.New("compatible cart input is not valid JSON") } response, err := client.postBody(ctx, endpoint, body, "application/json") if err != nil { return err } return writeRaw(stdout, response) } func runFinderStreamItems(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { body, err := readRequiredBody(stdin, args, "finder stream-items") if err != nil { return err } var ids []string for _, line := range strings.Split(string(body), "\n") { line = strings.TrimSpace(line) if line == "" { continue } if _, err := strconv.ParseUint(line, 10, 32); err != nil { return fmt.Errorf("finder stream-items: invalid item id %q", line) } ids = append(ids, line) } if len(ids) == 0 { return errors.New("finder stream-items input contains no item ids") } response, err := client.postBody(ctx, client.finderURL("/stream-items"), []byte(strings.Join(ids, "\n")+"\n"), "text/plain") if err != nil { return err } return writeRaw(stdout, response) } func runFinderSaveTrigger(ctx context.Context, client *Client, stdout io.Writer) error { body, err := client.postBody(ctx, client.finderURL("/save-trigger"), nil, "") if err != nil { return err } return writeRaw(stdout, body) } func runFinderAdmin(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { if len(args) == 0 || args[0] == "help" || args[0] == "--help" { _, _ = io.WriteString(stdout, "Usage: cmsctl finder admin settings {get|put}\n cmsctl finder admin facets {list|create|update|delete}\n cmsctl finder admin update-by-filter [file|-]\n cmsctl finder admin delete-by-filter [file|-] --yes\n") return nil } switch args[0] { case "settings": if len(args) < 2 || args[1] == "get" { body, err := client.get(ctx, client.finderAdminURL("/settings")) if err != nil { return err } return writeRaw(stdout, body) } if args[1] != "put" { return fmt.Errorf("unknown finder admin settings command %q", args[1]) } body, err := readRequiredBody(stdin, args[2:], "finder settings") if err != nil { return err } if !json.Valid(body) { return errors.New("finder settings input is not valid JSON") } response, err := client.putBody(ctx, client.finderAdminURL("/settings"), body, "application/json") if err != nil { return err } return writeRaw(stdout, response) case "facets": if len(args) < 2 || args[1] == "list" { body, err := client.get(ctx, client.finderAdminURL("/facets")) if err != nil { return err } return writeRaw(stdout, body) } id, err := requireID(args[2:], "finder admin facets requires a facet id") if err != nil && args[1] != "create" { return err } switch args[1] { case "create": body, readErr := readRequiredBody(stdin, args[2:], "finder facet") if readErr != nil { return readErr } response, reqErr := client.postBody(ctx, client.finderAdminURL("/facets"), body, "application/json") if reqErr != nil { return reqErr } return writeRaw(stdout, response) case "update": body, readErr := readRequiredBody(stdin, args[3:], "finder facet") if readErr != nil { return readErr } response, reqErr := client.putBody(ctx, client.finderAdminURL("/facets/"+escapePathSegment(id)), body, "application/json") if reqErr != nil { return reqErr } return writeRaw(stdout, response) case "delete": fs := flag.NewFlagSet("finder admin facets delete", flag.ContinueOnError) confirmed := fs.Bool("yes", false, "confirm deletion") fs.SetOutput(io.Discard) if err := fs.Parse(args[3:]); err != nil { return err } if !*confirmed { return errors.New("finder admin facets delete requires --yes") } body, reqErr := client.delete(ctx, client.finderAdminURL("/facets/"+escapePathSegment(id))) if reqErr != nil { return reqErr } return writeRaw(stdout, body) default: return fmt.Errorf("unknown finder admin facets command %q", args[1]) } case "update-by-filter", "delete-by-filter": body, err := readRequiredBody(stdin, args[1:], "finder "+args[0]) if err != nil { return err } if !json.Valid(body) { return errors.New("finder filter input is not valid JSON") } if args[0] == "delete-by-filter" { fs := flag.NewFlagSet("finder admin delete-by-filter", flag.ContinueOnError) confirmed := fs.Bool("yes", false, "confirm deletion") fs.SetOutput(io.Discard) if err := fs.Parse(args[1:]); err != nil { return err } if !*confirmed { return errors.New("finder admin delete-by-filter requires --yes") } response, reqErr := client.deleteBody(ctx, client.finderAdminURL("/items/by-filter"), body, "application/json") if reqErr != nil { return reqErr } return writeRaw(stdout, response) } response, err := client.postBody(ctx, client.finderAdminURL("/items/batch/by-filter"), body, "application/json") if err != nil { return err } return writeRaw(stdout, response) default: return fmt.Errorf("unknown finder admin command %q", args[0]) } }