feat(platform): add cmsctl and document newsletter and shell updates
Generated with Codebuff 🤖 Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
1 parent
0f8ed97878
commit
fea6eb51b1
7 files changed
+2408
No files matched your search
@@ -0,0 +1,434 @@
|
||||
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
|
||||
Reference in new issue
Block a user