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

407 lines
13 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 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)
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 "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)
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 *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
}