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,406 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user