package main import ( "context" "encoding/json" "errors" "flag" "fmt" "io" "net/http" "net/url" "strconv" "strings" ) func runCMS(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 nodes {list|get|create|update|patch|delete|retype} ...\n cmsctl cms edges {get|create|delete|reorder} ...\n cmsctl cms schemas|templates|blueprints|tree|nav|graph|publish|regenerate|script ...\n cmsctl cms search --q text\n cmsctl cms types\n cmsctl cms import [file|-]\n") return nil } switch args[0] { case "nodes": return runCMSNodes(ctx, client, args[1:], stdin, stdout) case "search": fs := flag.NewFlagSet("cms search", flag.ContinueOnError) q := fs.String("q", "", "search query") types := fs.String("type", "", "comma-separated node types") tag := fs.String("tag", "", "tag filter") limit := fs.Int("limit", 50, "page size") offset := fs.Int("offset", 0, "page offset") fs.SetOutput(io.Discard) if err := fs.Parse(args[1:]); err != nil { return err } if strings.TrimSpace(*q) == "" { return errors.New("cms search requires --q") } query := url.Values{"q": {*q}, "limit": {strconv.Itoa(*limit)}, "offset": {strconv.Itoa(*offset)}} if *types != "" { for _, typ := range splitCSV(*types) { query.Add("type", typ) } } if *tag != "" { query.Set("tag", *tag) } body, err := client.get(ctx, client.cmsURL("/search")+"?"+query.Encode()) if err != nil { return err } return writeRaw(stdout, body) case "types": body, err := client.get(ctx, client.cmsURL("/types")) if err != nil { return err } return writeRaw(stdout, body) case "edges": return runCMSEdges(ctx, client, args[1:], stdin, stdout) case "schemas": return runCMSSchemas(ctx, client, args[1:], stdin, stdout) case "templates": return runCMSTemplates(ctx, client, args[1:], stdin, stdout) case "blueprints": return runCMSBlueprints(ctx, client, args[1:], stdin, stdout) case "tree": return runCMSTree(ctx, client, args[1:], stdout) case "nav": return runCMSNav(ctx, client, args[1:], stdout) case "graph": return runCMSGraph(ctx, client, args[1:], stdout) case "publish": return runCMSPublish(ctx, client, args[1:], stdin, stdout) case "regenerate": return runCMSRegenerate(ctx, client, args[1:], stdout) case "script": return runCMSScript(ctx, client, args[1:], stdin, stdout) case "import": body, err := readInput(stdin, inputFlag(args[1:]), positionalWithoutInput(args[1:])) if err != nil { return fmt.Errorf("read CMS import: %w", err) } if len(body) == 0 { return errors.New("CMS import requires JSON input or a file") } if !json.Valid(body) { return errors.New("CMS import input is not valid JSON") } response, err := client.postBody(ctx, client.cmsURL("/import"), body, "application/json") if err != nil { return err } return writeRaw(stdout, response) default: return fmt.Errorf("unknown cms command %q", args[0]) } } func runCMSNodes(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { if len(args) == 0 { return errors.New("cms nodes requires list, get, create, or update") } switch args[0] { case "list": fs := flag.NewFlagSet("cms nodes list", flag.ContinueOnError) typ := fs.String("type", "", "node type") q := fs.String("q", "", "substring query") tag := fs.String("tag", "", "tag filter") where := fs.String("where", "", "data filter, e.g. pageId=88436") limit := fs.Int("limit", 50, "page size; use 0 for all") offset := fs.Int("offset", 0, "page offset") fs.SetOutput(io.Discard) if err := fs.Parse(args[1:]); err != nil { return err } query := url.Values{"limit": {strconv.Itoa(*limit)}, "offset": {strconv.Itoa(*offset)}} if *typ != "" { query.Set("type", *typ) } if *q != "" { query.Set("q", *q) } if *tag != "" { query.Set("tag", *tag) } if *where != "" { query.Set("where", *where) } body, err := client.get(ctx, client.cmsURL("/nodes")+"?"+query.Encode()) if err != nil { return err } return writeRaw(stdout, body) case "get": if len(args) < 2 || args[1] == "" || strings.HasPrefix(args[1], "-") { return errors.New("cms nodes get requires a node id") } body, err := client.get(ctx, client.cmsURL("/nodes/"+escapePathSegment(args[1]))) if err != nil { return err } return writeRaw(stdout, body) case "create": return cmsCreateNode(ctx, client, args[1:], stdin, stdout) case "update": return cmsUpdateNode(ctx, client, args[1:], stdin, stdout) case "patch": return cmsPatchNode(ctx, client, args[1:], stdin, stdout) case "delete": return cmsDeleteNode(ctx, client, args[1:], stdout) case "retype": return cmsRetypeNode(ctx, client, args[1:], stdin, stdout) default: return fmt.Errorf("unknown cms nodes command %q", args[0]) } } func cmsCreateNode(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { fs := flag.NewFlagSet("cms nodes create", flag.ContinueOnError) typ := fs.String("type", "", "node data type") data := fs.String("data", "", "JSON data object, @file, or - for stdin") tags := fs.String("tags", "", "comma-separated tags") 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 fmt.Errorf("read node input: %w", err) } if len(body) == 0 { dataBody, err := readJSONValue(stdin, *data) if err != nil { return fmt.Errorf("create node: %w", err) } if !json.Valid(dataBody) { return errors.New("--data is not valid JSON") } if *typ == "" { return errors.New("create node requires --type when --input is not used") } var dataObject map[string]any if err := json.Unmarshal(dataBody, &dataObject); err != nil { return fmt.Errorf("create node data must be a JSON object: %w", err) } body, err = json.Marshal(map[string]any{"dataType": *typ, "data": dataObject, "tags": splitCSV(*tags)}) if err != nil { return err } } if !json.Valid(body) { return errors.New("create node input is not valid JSON") } response, err := client.postBody(ctx, client.cmsURL("/nodes"), body, "application/json") if err != nil { return err } return writeRaw(stdout, response) } func cmsUpdateNode(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { if len(args) == 0 || args[0] == "" || strings.HasPrefix(args[0], "-") { return errors.New("cms nodes update requires a node id") } id := args[0] fs := flag.NewFlagSet("cms nodes update", flag.ContinueOnError) data := fs.String("data", "", "replacement JSON data object, @file, or - for stdin") tags := fs.String("tags", "", "comma-separated replacement tags") input := fs.String("input", "", "complete update 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 fmt.Errorf("read node update: %w", err) } if len(body) == 0 { dataBody, err := readJSONValue(stdin, *data) if err != nil { return fmt.Errorf("update node: %w", err) } if !json.Valid(dataBody) { return errors.New("--data is not valid JSON") } var dataObject map[string]any if err := json.Unmarshal(dataBody, &dataObject); err != nil { return fmt.Errorf("update node data must be a JSON object: %w", err) } body, err = json.Marshal(map[string]any{"data": dataObject, "tags": splitCSV(*tags)}) if err != nil { return err } } if !json.Valid(body) { return errors.New("update node input is not valid JSON") } response, err := client.putBody(ctx, client.cmsURL("/nodes/"+escapePathSegment(id)), body, "application/json") if err != nil { return err } return writeRaw(stdout, response) } func runFinder(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 search|get|config|facets|facet-list|values|stream-items|suggest|popular|related|compatible|similar|relation-groups|facet-groups|save-trigger|admin|import\n") return nil } switch args[0] { case "search": fs := flag.NewFlagSet("finder search", flag.ContinueOnError) q := fs.String("q", "", "search query") limit := fs.Int("limit", 40, "result page size") page := fs.Int("page", 0, "result page") sortName := fs.String("sort", "popular", "sort name") mode := fs.String("mode", "", "exact, prefix, fuzzy, bm25, or embeddings") filters := fs.String("filters", "", "JSON object with string/range filters") stock := fs.String("stock", "", "comma-separated stock locations") skipFacets := fs.String("skip-facets", "", "comma-separated facet ids to omit") disableGrouping := fs.Bool("disable-grouping", false, "do not collapse variants") skipTracking := fs.Bool("skip-tracking", false, "do not emit a search tracking event") fs.SetOutput(io.Discard) if err := fs.Parse(args[1:]); err != nil { return err } request := map[string]any{"query": *q, "pageSize": *limit, "page": *page, "sort": *sortName, "disableGrouping": *disableGrouping, "skipTracking": *skipTracking} if *mode != "" { request["mode"] = *mode } if *stock != "" { request["stock"] = splitCSV(*stock) } if *skipFacets != "" { request["skipFacets"] = splitCSV(*skipFacets) } if *filters != "" { var filterObject map[string]any if err := json.Unmarshal([]byte(*filters), &filterObject); err != nil { return fmt.Errorf("--filters is not valid JSON: %w", err) } for key, value := range filterObject { request[key] = value } } body, err := client.postJSON(ctx, client.finderURL("/stream"), request) if err != nil { return err } return writeRaw(stdout, body) case "get": return finderSimpleGet(ctx, client, args[1:], "get", stdout) case "facets": return runFinderFacets(ctx, client, args[1:], stdout) case "config": return runFinderConfig(ctx, client, args[1:], stdout) case "facet-list": return finderSimpleGet(ctx, client, nil, "facet-list", stdout) case "values": return finderSimpleGet(ctx, client, args[1:], "values", stdout) case "relation-groups", "facet-groups": return finderSimpleGet(ctx, client, nil, args[0], stdout) case "stream-items": return runFinderStreamItems(ctx, client, args[1:], stdin, stdout) case "save-trigger": return runFinderSaveTrigger(ctx, client, stdout) case "suggest": fs := flag.NewFlagSet("finder suggest", flag.ContinueOnError) q := fs.String("q", "", "suggestion prefix") fs.SetOutput(io.Discard) if err := fs.Parse(args[1:]); err != nil { return err } if *q == "" { return errors.New("finder suggest requires --q") } return finderGetQuery(ctx, client, "suggest", url.Values{"q": {*q}}, stdout) case "popular": return finderSimpleGet(ctx, client, nil, "popular", stdout) case "related", "similar": return runFinderRelated(ctx, client, args[0], args[1:], stdout) case "compatible": return runFinderCompatible(ctx, client, args[1:], stdin, stdout) case "admin": return runFinderAdmin(ctx, client, args[1:], stdin, stdout) case "import": return finderImport(ctx, client, args[1:], stdin, stdout) default: return fmt.Errorf("unknown finder command %q", args[0]) } } func finderSimpleGet(ctx context.Context, client *Client, args []string, endpoint string, stdout io.Writer) error { if endpoint != "facets" && endpoint != "popular" && len(args) == 0 { return fmt.Errorf("finder %s requires an item id or SKU", endpoint) } urlPath := endpoint if len(args) > 0 { urlPath += "/" + escapePathSegment(args[0]) } body, err := client.get(ctx, client.finderURL("/"+urlPath)) if err != nil { return err } return writeRaw(stdout, body) } func finderGetQuery(ctx context.Context, client *Client, endpoint string, query url.Values, stdout io.Writer) error { body, err := client.get(ctx, client.finderURL("/"+endpoint)+"?"+query.Encode()) if err != nil { return err } return writeRaw(stdout, body) } func finderImport(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { input := inputFlag(args) body, err := readInput(stdin, input, positionalWithoutInput(args)) if err != nil { return fmt.Errorf("read finder import: %w", err) } if len(body) == 0 { return errors.New("finder import requires JSON/JSONL input or a file") } items, err := scanJSONLines(body) if err != nil { return fmt.Errorf("parse finder import: %w", err) } payload, err := json.Marshal(items) if err != nil { return err } response, err := client.postBody(ctx, client.finderAdminURL("/items/batch"), payload, "application/json") if err != nil { return err } return writeRaw(stdout, response) } func inputFlag(args []string) string { for i, arg := range args { if arg == "--input" && i+1 < len(args) { return args[i+1] } if strings.HasPrefix(arg, "--input=") { return strings.TrimPrefix(arg, "--input=") } } return "" } func positionalWithoutInput(args []string) []string { out := make([]string, 0, len(args)) skip := false for _, arg := range args { if skip { skip = false continue } if arg == "--input" { skip = true continue } if strings.HasPrefix(arg, "--input=") { continue } if arg == "-" { out = append(out, arg) continue } if !strings.HasPrefix(arg, "-") { out = append(out, arg) } } return out } func writeRaw(w io.Writer, body []byte) error { if len(body) == 0 { return nil } if _, err := w.Write(body); err != nil { return err } if body[len(body)-1] != '\n' { _, err := io.WriteString(w, "\n") return err } return nil } // Keep the compiler honest if a future command adds a status-only DELETE. var _ = http.MethodDelete