429 lines
14 KiB
Go
429 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
const usageText = `cmsctl — CMS and finder API CLI
|
|
|
|
Usage:
|
|
cmsctl auth login [--scope read,write] Device-login and persist token
|
|
cmsctl auth status Show current credential status
|
|
cmsctl auth token [--raw] Print the persisted token
|
|
cmsctl auth logout Remove the persisted token
|
|
|
|
cmsctl todos [list] [flags] List todos (?done, prio, q, limit)
|
|
cmsctl todos done <id> Mark a todo as completed
|
|
cmsctl todos reopen <id> Mark a todo as open / not done
|
|
cmsctl todos get <id> Get one todo node
|
|
cmsctl todos create --note '...' [flags] Create a new todo
|
|
cmsctl todos delete <id> --yes Delete a todo node
|
|
|
|
cmsctl cms nodes list [flags] List CMS nodes
|
|
cmsctl cms nodes get <id> Get one node
|
|
cmsctl cms nodes create [flags] Create a node
|
|
cmsctl cms nodes update <id> [flags] Replace a node
|
|
cmsctl cms nodes patch <id> [flags] Merge fields into a node
|
|
cmsctl cms nodes delete <id> --yes Delete a node
|
|
cmsctl cms nodes retype <id> --type TYPE Change node type
|
|
cmsctl cms edges {get|create|delete|reorder} Manage graph edges
|
|
cmsctl cms schemas {list|get|put|delete} Manage JSON Schemas
|
|
cmsctl cms templates {list|get|put|delete} Manage render templates
|
|
cmsctl cms blueprints {list|create} Inspect/apply blueprints
|
|
cmsctl cms tree <root-id> Read a composition tree
|
|
cmsctl cms nav [root-id] Read navigation hierarchy
|
|
cmsctl cms graph [flags] Read a graph region
|
|
cmsctl cms publish <root-id>... Publish roots
|
|
cmsctl cms regenerate [--type TYPE] Regenerate static output
|
|
cmsctl cms script --script '...' Run a CMS script
|
|
cmsctl cms search --q <text> Search CMS nodes
|
|
cmsctl cms types List registered node types
|
|
cmsctl cms import [file|-] Import nodes and edges
|
|
|
|
cmsctl finder search [flags] Search catalog (JSONL output)
|
|
cmsctl finder get <id-or-sku> Get one catalog item
|
|
cmsctl finder config [flags] Browse items and facets
|
|
cmsctl finder facets [flags] List query facets
|
|
cmsctl finder facet-list List configured facets
|
|
cmsctl finder values <facet-id> List values for a facet
|
|
cmsctl finder stream-items [file|-] Stream items by numeric IDs
|
|
cmsctl finder suggest --q <text> Catalog suggestions
|
|
cmsctl finder popular Popular catalog items
|
|
cmsctl finder related <id> Related catalog items
|
|
cmsctl finder compatible <id> Compatible catalog items
|
|
cmsctl finder similar <id> Visually similar items
|
|
cmsctl finder relation-groups|facet-groups Read finder configuration
|
|
cmsctl finder save-trigger Ask reader to persist state
|
|
cmsctl finder import [file|-] Import inventory items (JSON/JSONL)
|
|
|
|
cmsctl commerce promotions {list|get|upsert|status|delete} ...
|
|
cmsctl commerce value-codes definitions {get|replace} ...
|
|
cmsctl commerce carts {get|add|set|remove} ...
|
|
cmsctl commerce checkout create <cart-id> ...
|
|
cmsctl commerce orders {get|value-codes} ...
|
|
|
|
All commands accept --config, --base-url, --finder-url, --api-key, --token,
|
|
and --timeout before the command. A missing --finder-url uses <base-url>/find
|
|
for reads. Finder administration always uses <base-url>/catalog/admin.
|
|
|
|
Pipe examples:
|
|
cat nodes.json | cmsctl cms import -
|
|
jq '{data: .data, tags: .tags}' node.json | cmsctl cms nodes update ID --input -
|
|
cat inventory.jsonl | cmsctl finder import -
|
|
cmsctl finder search --q drill --limit 20 | jq -c .
|
|
`
|
|
|
|
type globalOptions struct {
|
|
configPath string
|
|
baseURL string
|
|
finderURL string
|
|
apiKey string
|
|
token string
|
|
timeout time.Duration
|
|
}
|
|
|
|
func main() {
|
|
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
|
fmt.Fprintln(os.Stderr, "cmsctl:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run(args []string, stdout, stderr io.Writer) error {
|
|
return runWithStdin(args, os.Stdin, stdout, stderr)
|
|
}
|
|
|
|
func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
|
|
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
|
|
_, _ = io.WriteString(stdout, usageText)
|
|
return nil
|
|
}
|
|
global, rest, err := parseGlobal(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(rest) == 0 {
|
|
_, _ = io.WriteString(stdout, usageText)
|
|
return nil
|
|
}
|
|
|
|
cfgPath, err := configPath(global.configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg, err := loadConfig(cfgPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if global.baseURL != "" {
|
|
cfg.BaseURL = global.baseURL
|
|
}
|
|
if global.finderURL != "" {
|
|
cfg.FinderURL = global.finderURL
|
|
}
|
|
if global.token == "" {
|
|
global.token = os.Getenv("CMSCTL_TOKEN")
|
|
}
|
|
if global.apiKey == "" {
|
|
global.apiKey = os.Getenv("CMSCTL_API_KEY")
|
|
}
|
|
if global.token == "" && global.apiKey == "" {
|
|
global.token = cfg.AccessToken
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
switch rest[0] {
|
|
case "auth":
|
|
return runAuth(ctx, cfgPath, &cfg, rest[1:], stdout, stderr, global.timeout)
|
|
case "todos", "todo":
|
|
client := newClient(cfg, global.token, global.apiKey, global.timeout)
|
|
return runTodos(ctx, client, rest[1:], stdin, stdout)
|
|
case "cms":
|
|
client := newClient(cfg, global.token, global.apiKey, global.timeout)
|
|
return runCMS(ctx, client, rest[1:], stdin, stdout)
|
|
case "finder", "inventory":
|
|
client := newClient(cfg, global.token, global.apiKey, global.timeout)
|
|
return runFinder(ctx, client, rest[1:], stdin, stdout)
|
|
case "commerce":
|
|
client := newClient(cfg, global.token, global.apiKey, global.timeout)
|
|
return runCommerce(ctx, client, rest[1:], stdin, stdout)
|
|
default:
|
|
return fmt.Errorf("unknown command %q (use --help)", rest[0])
|
|
}
|
|
}
|
|
|
|
func parseGlobal(args []string) (globalOptions, []string, error) {
|
|
g := globalOptions{timeout: 30 * time.Second}
|
|
for len(args) > 0 {
|
|
arg := args[0]
|
|
if arg == "--" {
|
|
return g, args[1:], nil
|
|
}
|
|
if !strings.HasPrefix(arg, "-") {
|
|
return g, args, nil
|
|
}
|
|
args = args[1:]
|
|
name, value, hasValue := strings.Cut(arg, "=")
|
|
if !hasValue {
|
|
if len(args) == 0 {
|
|
return g, nil, fmt.Errorf("%s requires a value", name)
|
|
}
|
|
value, args = args[0], args[1:]
|
|
}
|
|
switch name {
|
|
case "--config":
|
|
g.configPath = value
|
|
case "--base-url":
|
|
g.baseURL = value
|
|
case "--finder-url":
|
|
g.finderURL = value
|
|
case "--api-key":
|
|
g.apiKey = value
|
|
case "--token":
|
|
g.token = value
|
|
case "--timeout":
|
|
d, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return g, nil, fmt.Errorf("invalid --timeout %q: %w", value, err)
|
|
}
|
|
g.timeout = d
|
|
default:
|
|
return g, nil, fmt.Errorf("unknown global flag %q", name)
|
|
}
|
|
}
|
|
return g, args, nil
|
|
}
|
|
|
|
func runAuth(ctx context.Context, cfgPath string, cfg *Config, args []string, stdout, stderr io.Writer, timeout time.Duration) error {
|
|
if len(args) == 0 || args[0] == "help" || args[0] == "--help" {
|
|
_, _ = io.WriteString(stdout, "Usage: cmsctl auth login [--scope read,write] [--no-poll]\n cmsctl auth status\n cmsctl auth token [--raw]\n cmsctl auth logout\n")
|
|
return nil
|
|
}
|
|
switch args[0] {
|
|
case "login":
|
|
fs := flag.NewFlagSet("auth login", flag.ContinueOnError)
|
|
fs.SetOutput(stderr)
|
|
scopes := fs.String("scope", "read,write", "comma-separated requested scopes")
|
|
noPoll := fs.Bool("no-poll", false, "print the grant without polling for approval")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return err
|
|
}
|
|
requested := splitCSV(*scopes)
|
|
client := newClient(*cfg, "", "", timeout)
|
|
endpoint := effectiveBaseURL(*cfg) + "/api/auth/device"
|
|
if len(requested) > 0 {
|
|
endpoint += "?scope=" + strings.Join(escapeQueryValues(requested), ",")
|
|
}
|
|
body, err := client.postBody(ctx, endpoint, nil, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var grant struct {
|
|
DeviceCode string `json:"device_code"`
|
|
UserCode string `json:"user_code"`
|
|
VerificationURI string `json:"verification_uri"`
|
|
VerificationURIComplete string `json:"verification_uri_complete"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
Interval int `json:"interval"`
|
|
}
|
|
if err := json.Unmarshal(body, &grant); err != nil {
|
|
return fmt.Errorf("decode device grant: %w", err)
|
|
}
|
|
fmt.Fprintf(stderr, "Open %s\nCode: %s\n", grant.VerificationURIComplete, grant.UserCode)
|
|
if *noPoll {
|
|
return nil
|
|
}
|
|
if grant.DeviceCode == "" {
|
|
return errors.New("device grant did not include device_code")
|
|
}
|
|
interval := time.Duration(grant.Interval) * time.Second
|
|
if interval <= 0 {
|
|
interval = 5 * time.Second
|
|
}
|
|
deadline := time.NewTimer(time.Duration(grant.ExpiresIn) * time.Second)
|
|
defer deadline.Stop()
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-deadline.C:
|
|
return errors.New("device login expired before approval")
|
|
case <-ticker.C:
|
|
pollBody, pollErr := client.postJSON(ctx, effectiveBaseURL(*cfg)+"/api/auth/device/token", map[string]string{
|
|
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
"device_code": grant.DeviceCode,
|
|
})
|
|
if pollErr != nil {
|
|
var httpErr *HTTPError
|
|
if errors.As(pollErr, &httpErr) {
|
|
var pending struct {
|
|
Error string `json:"error"`
|
|
}
|
|
if json.Unmarshal(httpErr.Body, &pending) == nil && pending.Error == "authorization_pending" {
|
|
continue
|
|
}
|
|
}
|
|
return pollErr
|
|
}
|
|
var token struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
}
|
|
if err := json.Unmarshal(pollBody, &token); err != nil {
|
|
return fmt.Errorf("decode access token: %w", err)
|
|
}
|
|
if token.AccessToken == "" {
|
|
return errors.New("device token response did not include access_token")
|
|
}
|
|
cfg.AccessToken = token.AccessToken
|
|
cfg.TokenType = token.TokenType
|
|
cfg.ExpiresAt = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
|
|
if err := saveConfig(cfgPath, *cfg); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(stderr, "Authenticated; token saved to %s\n", cfgPath)
|
|
return nil
|
|
}
|
|
}
|
|
case "status":
|
|
status := map[string]any{
|
|
"config": cfgPath,
|
|
"base_url": effectiveBaseURL(*cfg),
|
|
"finder_url": effectiveFinderURL(*cfg),
|
|
"authenticated": cfg.AccessToken != "" && (cfg.ExpiresAt.IsZero() || time.Now().Before(cfg.ExpiresAt)),
|
|
}
|
|
if !cfg.ExpiresAt.IsZero() {
|
|
status["expires_at"] = cfg.ExpiresAt
|
|
}
|
|
return writeJSON(stdout, status)
|
|
case "token":
|
|
fs := flag.NewFlagSet("auth token", flag.ContinueOnError)
|
|
fs.SetOutput(stderr)
|
|
raw := fs.Bool("raw", false, "print only the token")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return err
|
|
}
|
|
if cfg.AccessToken == "" {
|
|
return errors.New("no persisted access token; run auth login")
|
|
}
|
|
if !cfg.ExpiresAt.IsZero() && !time.Now().Before(cfg.ExpiresAt) {
|
|
return fmt.Errorf("persisted access token expired at %s; run auth login", cfg.ExpiresAt.Format(time.RFC3339))
|
|
}
|
|
if *raw {
|
|
_, err := fmt.Fprintln(stdout, cfg.AccessToken)
|
|
return err
|
|
}
|
|
return writeJSON(stdout, map[string]any{"access_token": cfg.AccessToken, "token_type": cfg.TokenType, "expires_at": cfg.ExpiresAt})
|
|
case "logout":
|
|
clearToken(cfg)
|
|
if err := saveConfig(cfgPath, *cfg); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(stdout, "Logged out; token removed from %s\n", cfgPath)
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unknown auth command %q", args[0])
|
|
}
|
|
}
|
|
|
|
func writeJSON(w io.Writer, value any) error {
|
|
enc := json.NewEncoder(w)
|
|
enc.SetEscapeHTML(false)
|
|
return enc.Encode(value)
|
|
}
|
|
|
|
func readInput(stdin io.Reader, inputPath string, positional []string) ([]byte, error) {
|
|
if inputPath == "" && len(positional) > 0 {
|
|
inputPath = positional[0]
|
|
}
|
|
if inputPath == "" {
|
|
return nil, nil
|
|
}
|
|
if inputPath == "-" {
|
|
return io.ReadAll(stdin)
|
|
}
|
|
return os.ReadFile(filepath.Clean(inputPath))
|
|
}
|
|
|
|
func readJSONValue(stdin io.Reader, raw string) (json.RawMessage, error) {
|
|
if raw == "" {
|
|
return nil, errors.New("JSON value is required")
|
|
}
|
|
if raw == "-" {
|
|
body, err := io.ReadAll(stdin)
|
|
return body, err
|
|
}
|
|
if strings.HasPrefix(raw, "@") {
|
|
return os.ReadFile(filepath.Clean(strings.TrimPrefix(raw, "@")))
|
|
}
|
|
return []byte(raw), nil
|
|
}
|
|
|
|
func splitCSV(raw string) []string {
|
|
var out []string
|
|
for _, value := range strings.Split(raw, ",") {
|
|
if value = strings.TrimSpace(value); value != "" {
|
|
out = append(out, value)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func escapeQueryValues(values []string) []string {
|
|
out := make([]string, len(values))
|
|
for i, value := range values {
|
|
out[i] = urlQueryEscape(value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func urlQueryEscape(value string) string {
|
|
return url.QueryEscape(value)
|
|
}
|
|
|
|
func scanJSONLines(body []byte) ([]json.RawMessage, error) {
|
|
var array []json.RawMessage
|
|
if json.Unmarshal(body, &array) == nil {
|
|
return array, nil
|
|
}
|
|
scanner := bufio.NewScanner(strings.NewReader(string(body)))
|
|
scanner.Buffer(make([]byte, 64*1024), maxResponseBytes)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var raw json.RawMessage
|
|
if !json.Valid([]byte(line)) {
|
|
return nil, fmt.Errorf("invalid JSONL line %d", len(array)+1)
|
|
}
|
|
raw = append(raw, line...)
|
|
array = append(array, raw)
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(array) == 0 {
|
|
return nil, errors.New("input contains no JSON objects")
|
|
}
|
|
return array, nil
|
|
}
|