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 Mark a todo as completed cmsctl todos reopen Mark a todo as open / not done cmsctl todos get Get one todo node cmsctl todos create --note '...' [flags] Create a new todo cmsctl todos delete --yes Delete a todo node cmsctl cms nodes list [flags] List CMS nodes cmsctl cms nodes get Get one node cmsctl cms nodes create [flags] Create a node cmsctl cms nodes update [flags] Replace a node cmsctl cms nodes patch [flags] Merge fields into a node cmsctl cms nodes delete --yes Delete a node cmsctl cms nodes retype --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 Read a composition tree cmsctl cms nav [root-id] Read navigation hierarchy cmsctl cms graph [flags] Read a graph region cmsctl cms publish ... Publish roots cmsctl cms regenerate [--type TYPE] Regenerate static output cmsctl cms script --script '...' Run a CMS script cmsctl cms search --q 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 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 List values for a facet cmsctl finder stream-items [file|-] Stream items by numeric IDs cmsctl finder suggest --q Catalog suggestions cmsctl finder popular Popular catalog items cmsctl finder related Related catalog items cmsctl finder compatible Compatible catalog items cmsctl finder similar 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 /find for reads. Finder administration always uses /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) 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 }