435 lines
14 KiB
Go
435 lines
14 KiB
Go
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
|