Files
platform/cmd/cmsctl/extra_commands.go
T
2026-08-20 18:13:49 +02:00

751 lines
23 KiB
Go

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 <node-id>\n cmsctl cms edges create [--from ID --to ID --type child --handle NAME --sort N]\n cmsctl cms edges delete <edge-id> --yes\n cmsctl cms edges reorder <parent-id> --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])
}
}