Files
platform/cmd/cmsctl/commands.go
T
2026-08-22 10:12:22 +02:00

437 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 "todos", "todo":
return runTodos(ctx, client, args[1:], stdin, stdout)
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