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:
mats committed 2026-08-20 18:13:49 +02:00
1 parent 0f8ed97878
commit fea6eb51b1
7 files changed
+2408

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
# cmsctl
`cmsctl` is a dependency-free Go CLI for the platform's backoffice APIs. It
uses the existing RFC 8628 device-login flow and stores the resulting bearer
token **together with the instance base URL** in:
- `$CMSCTL_CONFIG`, when set
- `$XDG_CONFIG_HOME/cmsctl/config.json`, when set
- the platform config directory from Go's `os.UserConfigDir()` otherwise:
- Unix/Linux: `~/.config/cmsctl/config.json`
- macOS: `~/Library/Application Support/cmsctl/config.json`
- Windows: `%AppData%\\cmsctl\\config.json`
The persisted JSON uses `base_url` for the instance URL and `access_token` for
the device token.
The file and its parent directory are created with restrictive permissions
(`0700`/`0600`). The token is never printed during login; login progress goes to
stderr so stdout remains pipe-safe.
## Build and run
From `platform/`:
```bash
go build -o cmsctl ./cmd/cmsctl
go run ./cmd/cmsctl --help
```
The default backoffice URL is `http://localhost:8080`.
## Authenticate
```bash
cmsctl auth login --scope read,write
# Open the displayed verification URL and approve the code as an admin.
cmsctl auth status
cmsctl auth token --raw | secret-tool store --label=cmsctl token
cmsctl auth logout
```
The token can also be supplied per invocation with `--token` or
`CMSCTL_TOKEN`; a minted API key can be supplied with `--api-key` or
`CMSCTL_API_KEY`.
Global connection flags must appear before the command:
```bash
cmsctl --base-url https://shop.example cms nodes list --type page
cmsctl --finder-url http://finder:8080 finder search --q drill
cmsctl finder import inventory.json
```
When `--finder-url` is omitted, finder reads use `<base-url>/find`, which is the
backoffice proxy. Finder administration always uses `<base-url>/catalog/admin`.
The CLI intentionally has no separate finder-admin URL: the backoffice is the
writeradmin boundary.
## CMS
```bash
cmsctl cms nodes list --type page --limit 100
cmsctl cms nodes get NODE_ID
cmsctl cms search --q "summer sale" --type page
cmsctl cms types
cmsctl cms nodes create \
--type page \
--data '{"title":"From the CLI"}' \
--tags published,homepage
cmsctl cms nodes update NODE_ID \
--data @replacement-data.json \
--tags published
```
`PUT /api/nodes/{id}` replaces the full `data` object. Use `cmsctl cms nodes
get` first and merge fields before updating, or use `--input` with a complete
`{"data":...,"tags":...}` request body. For a safe partial update, use the
native merge endpoint:
```bash
cmsctl cms nodes patch NODE_ID --data '{"data":{"title":"New"}}'
cmsctl cms nodes delete NODE_ID --yes
cmsctl cms nodes retype NODE_ID --type article
```
Graph and editorial operations are available directly:
```bash
cmsctl cms edges get NODE_ID
cmsctl cms edges create --from PAGE_ID --to BLOCK_ID --type child --sort 0
cmsctl cms edges reorder PAGE_ID --order EDGE_ID_1,EDGE_ID_2
cmsctl cms tree PAGE_ID --depth 3
cmsctl cms nav --data-type page
cmsctl cms graph --root PAGE_ID --budget 250 --edges child,ref
cmsctl cms publish PAGE_ID OTHER_PAGE_ID
cmsctl cms regenerate --type page
```
Schemas and templates can be inspected or edited in development mode. Writes
accept a file or `-` on stdin; destructive deletes require `--yes`:
```bash
cmsctl cms schemas list
cmsctl cms schemas get page > page.schema.json
cmsctl cms schemas put page page.schema.json
cmsctl cms templates get page.tmpl
cat page.tmpl | cmsctl cms templates put page -
cmsctl cms blueprints list
cmsctl cms blueprints create --blueprint landing --name "New landing"
```
The script command exposes the server-side CMS scripting API for atomic or
multi-step operations while retaining pipe-safe JSON output:
```bash
cmsctl cms script --script 'result(cms.listNodes({type:"todo",limit:0}))'
cat script-request.json | cmsctl cms script --input -
```
Bulk CMS import accepts the native `{nodes,edges}` request shape:
```bash
cmsctl cms import data/import.json
cat data/import.json | cmsctl cms import -
```
## Finder and inventory
Finder search emits the reader's JSONL response unchanged: product lines are
followed by the final search metadata line. This makes it easy to use with
`jq`, `awk`, or another streaming consumer.
```bash
cmsctl finder search --q drill --limit 20 --sort price_asc | jq -c .
cmsctl finder search --q window --filters '{"string":[{"id":"brand","value":["Dala"]}]}'
cmsctl finder get 861201
cmsctl finder get ABC-123 # SKU lookup is supported by finder
cmsctl finder config --stock in_stock
cmsctl finder facets --q drill
cmsctl finder facet-list
cmsctl finder values brand
cmsctl finder suggest --q dr
cmsctl finder popular
cmsctl finder related 861201
cmsctl finder compatible 861201 --limit 20
cmsctl finder compatible 861201 --cart-input cart-item-ids.json
cmsctl finder similar 861201
cmsctl finder relation-groups
cmsctl finder facet-groups
```
To materialize a bounded list of catalog items, pipe one numeric ID per line:
```bash
printf '861201\\n861202\\n' | cmsctl finder stream-items -
```
`finder search` also exposes the reader's important request controls:
`--filters`, `--stock`, `--sort`, `--mode`, `--disable-grouping`,
`--skip-facets`, and `--skip-tracking`. `--filters` is the JSON object sent as
the reader's `string`/`range` filter fields.
Writer administration is explicitly routed through the backoffice writeradmin
boundary (`<base-url>/catalog/admin`), never directly through `--finder-url`:
```bash
cmsctl finder admin settings get
cmsctl finder admin settings put settings.json
cmsctl finder admin facets list
cmsctl finder admin facets create facet.json
cmsctl finder admin facets update brand facet.json
cmsctl finder admin facets delete obsolete --yes
cmsctl finder admin update-by-filter update-request.json
cmsctl finder admin delete-by-filter filter.json --yes
```
The filter update body follows the writer contract:
`{"filter":{"string":[...] ,"range":[...]},"update":{...}}`. Filter
operations reject empty filters server-side; deletion also requires `--yes` in
the CLI.
Inventory/catalog imports accept either the writer's JSON array or one partial
item update per JSONL line. The CLI normalizes JSONL to the writer's batch
request before sending it to `POST /catalog/admin/items/batch`:
```bash
cat inventory.json | cmsctl finder import -
cat inventory.jsonl | cmsctl inventory import -
jq -c '.[]' products.json | cmsctl finder import -
```
Each finder import item follows the writer contract. For example:
```json
{"id":861201,"title":"New title","values":{"2":"Intel"}}
{"id":99000001,"buyable":true,"saleStatus":"ACT","values":{"4":50000}}
```
Updates are partial and merged by the finder writer; a `delete: true` item is a
catalog deletion. The persisted device token needs write authority for CMS and
finder mutations.
+178
View File
@@ -0,0 +1,178 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"time"
)
const maxResponseBytes = 64 << 20
type Client struct {
cfg Config
http *http.Client
token string
apiKey string
timeout time.Duration
}
func newClient(cfg Config, token, apiKey string, timeout time.Duration) *Client {
if timeout <= 0 {
timeout = 30 * time.Second
}
return &Client{
cfg: cfg,
http: &http.Client{Timeout: timeout},
token: token,
apiKey: apiKey,
timeout: timeout,
}
}
func (c *Client) cmsURL(endpoint string) string {
return joinURL(effectiveBaseURL(c.cfg), "/api", endpoint)
}
func (c *Client) finderURL(endpoint string) string {
return joinURL(effectiveFinderURL(c.cfg), "/api", endpoint)
}
// finderAdminURL is deliberately based on BaseURL, never FinderURL. The
// backoffice owns the writeradmin surface and is the only supported write path.
func (c *Client) finderAdminURL(endpoint string) string {
return joinURL(effectiveBaseURL(c.cfg), "/catalog/admin", endpoint)
}
func joinURL(base string, parts ...string) string {
u, err := url.Parse(strings.TrimRight(base, "/"))
if err != nil || u.Scheme == "" || u.Host == "" {
return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(path.Join(parts...), "/")
}
joined := strings.TrimRight(u.Path, "/")
for _, part := range parts {
joined = path.Join(joined, "/"+strings.Trim(part, "/"))
}
u.Path = joined
return u.String()
}
func escapePathSegment(value string) string {
return url.PathEscape(value)
}
func (c *Client) request(ctx context.Context, method, endpoint string, body []byte, contentType string) ([]byte, int, error) {
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/json, application/jsonl+json, text/plain")
if body != nil {
if contentType == "" {
contentType = "application/json"
}
req.Header.Set("Content-Type", contentType)
}
if c.apiKey != "" {
req.Header.Set("X-API-Key", c.apiKey)
} else if c.token != "" {
tokenType := c.cfg.TokenType
if tokenType == "" {
tokenType = "Bearer"
}
req.Header.Set("Authorization", tokenType+" "+c.token)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
if err != nil {
return nil, resp.StatusCode, err
}
if len(responseBody) > maxResponseBytes {
return nil, resp.StatusCode, fmt.Errorf("response exceeds %d MiB", maxResponseBytes/(1<<20))
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return responseBody, resp.StatusCode, &HTTPError{StatusCode: resp.StatusCode, Body: responseBody}
}
return responseBody, resp.StatusCode, nil
}
type HTTPError struct {
StatusCode int
Body []byte
}
func (e *HTTPError) Error() string {
body := strings.TrimSpace(string(e.Body))
if body == "" {
return fmt.Sprintf("HTTP %d", e.StatusCode)
}
var payload struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
Message string `json:"message"`
}
if json.Unmarshal(e.Body, &payload) == nil {
for _, msg := range []string{payload.ErrorDescription, payload.Message, payload.Error} {
if msg != "" {
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, msg)
}
}
}
if len(body) > 500 {
body = body[:500] + "…"
}
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, body)
}
func (c *Client) get(ctx context.Context, endpoint string) ([]byte, error) {
body, _, err := c.request(ctx, http.MethodGet, endpoint, nil, "")
return body, err
}
func (c *Client) postJSON(ctx context.Context, endpoint string, value any) ([]byte, error) {
body, err := json.Marshal(value)
if err != nil {
return nil, err
}
return c.postBody(ctx, endpoint, body, "application/json")
}
func (c *Client) postBody(ctx context.Context, endpoint string, body []byte, contentType string) ([]byte, error) {
response, _, err := c.request(ctx, http.MethodPost, endpoint, body, contentType)
return response, err
}
func (c *Client) putBody(ctx context.Context, endpoint string, body []byte, contentType string) ([]byte, error) {
response, _, err := c.request(ctx, http.MethodPut, endpoint, body, contentType)
return response, err
}
func (c *Client) patchBody(ctx context.Context, endpoint string, body []byte, contentType string) ([]byte, error) {
response, _, err := c.request(ctx, http.MethodPatch, endpoint, body, contentType)
return response, err
}
func (c *Client) delete(ctx context.Context, endpoint string) ([]byte, error) {
response, _, err := c.request(ctx, http.MethodDelete, endpoint, nil, "")
return response, err
}
func (c *Client) deleteBody(ctx context.Context, endpoint string, body []byte, contentType string) ([]byte, error) {
response, _, err := c.request(ctx, http.MethodDelete, endpoint, body, contentType)
return response, err
}
+295
View File
@@ -0,0 +1,295 @@
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestConfigRoundTripProtectsToken(t *testing.T) {
configFile := filepath.Join(t.TempDir(), "nested", "config.json")
want := Config{
BaseURL: "https://shop.example",
AccessToken: "secret-token",
TokenType: "Bearer",
ExpiresAt: time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC),
}
if err := saveConfig(configFile, want); err != nil {
t.Fatalf("saveConfig: %v", err)
}
got, err := loadConfig(configFile)
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if got.AccessToken != want.AccessToken || !got.ExpiresAt.Equal(want.ExpiresAt) || got.BaseURL != want.BaseURL {
t.Fatalf("round trip = %+v, want %+v", got, want)
}
info, err := os.Stat(configFile)
if err != nil {
t.Fatalf("stat config: %v", err)
}
if mode := info.Mode().Perm(); mode != 0o600 {
t.Fatalf("config mode = %o, want 600", mode)
}
}
func TestConfigPathHonorsXDGConfigHome(t *testing.T) {
xdg := t.TempDir()
t.Setenv("CMSCTL_CONFIG", "")
t.Setenv("XDG_CONFIG_HOME", xdg)
got, err := configPath("")
if err != nil {
t.Fatalf("configPath: %v", err)
}
want := filepath.Join(xdg, "cmsctl", "config.json")
if got != want {
t.Fatalf("configPath = %q, want %q", got, want)
}
}
func TestDeviceLoginPersistsTokenAndBaseURL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/auth/device":
_, _ = io.WriteString(w, `{"device_code":"device-1","user_code":"ABCD-EFGH","verification_uri_complete":"http://example/device?code=ABCD-EFGH","expires_in":10,"interval":1}`)
case "/api/auth/device/token":
_, _ = io.WriteString(w, `{"access_token":"persisted-token","token_type":"Bearer","expires_in":3600}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
configFile := filepath.Join(t.TempDir(), "config.json")
var stdout, stderr bytes.Buffer
err := runWithStdin(
[]string{"--config", configFile, "--base-url", server.URL, "auth", "login", "--scope", "read"},
strings.NewReader(""),
&stdout,
&stderr,
)
if err != nil {
t.Fatalf("login: %v (stderr=%s)", err, stderr.String())
}
cfg, err := loadConfig(configFile)
if err != nil {
t.Fatalf("load persisted config: %v", err)
}
if cfg.AccessToken != "persisted-token" {
t.Fatalf("access token = %q", cfg.AccessToken)
}
if cfg.BaseURL != server.URL {
t.Fatalf("base URL = %q, want %q", cfg.BaseURL, server.URL)
}
}
func TestClientSendsBearerToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/nodes" {
t.Errorf("path = %q, want /api/nodes", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer token-123" {
t.Errorf("authorization = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"items":[],"total":0,"offset":0,"limit":50}`)
}))
defer server.Close()
client := newClient(Config{BaseURL: server.URL, TokenType: "Bearer"}, "token-123", "", time.Second)
body, err := client.get(context.Background(), client.cmsURL("/nodes"))
if err != nil {
t.Fatalf("get: %v", err)
}
if !json.Valid(body) {
t.Fatalf("response is not JSON: %s", body)
}
}
func TestFinderImportUsesBackofficeBaseURL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/catalog/admin/items/batch" {
t.Errorf("finder import path = %q, want /catalog/admin/items/batch", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"status":"ok","inserts":1}`)
}))
defer server.Close()
client := newClient(Config{BaseURL: server.URL, FinderURL: "http://reader.invalid"}, "device-token", "", time.Second)
var stdout bytes.Buffer
if err := runFinder(context.Background(), client, []string{"import", "-"}, strings.NewReader(`{"id":1,"title":"Piped"}`), &stdout); err != nil {
t.Fatalf("finder import: %v", err)
}
if !strings.Contains(stdout.String(), `"inserts":1`) {
t.Fatalf("stdout = %q", stdout.String())
}
}
func TestRunCMSCreateFromStdin(t *testing.T) {
var gotAuth, gotBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
data, _ := io.ReadAll(r.Body)
gotBody = string(data)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"id":"node-1","dataType":"page","data":{"title":"Piped"}}`)
}))
defer server.Close()
configFile := filepath.Join(t.TempDir(), "config.json")
var stdout, stderr bytes.Buffer
err := runWithStdin(
[]string{"--config", configFile, "--base-url", server.URL, "--token", "device-token", "cms", "nodes", "create", "--input", "-"},
strings.NewReader(`{"dataType":"page","data":{"title":"Piped"}}`),
&stdout,
&stderr,
)
if err != nil {
t.Fatalf("run: %v (stderr=%s)", err, stderr.String())
}
if gotAuth != "Bearer device-token" {
t.Fatalf("authorization = %q", gotAuth)
}
if gotBody != `{"dataType":"page","data":{"title":"Piped"}}` {
t.Fatalf("request body = %q", gotBody)
}
if !strings.Contains(stdout.String(), `"node-1"`) {
t.Fatalf("stdout = %q", stdout.String())
}
}
func TestScanJSONLinesAcceptsArrayAndJSONL(t *testing.T) {
for _, input := range []string{
`[{"id":1},{"id":2}]`,
"{\"id\":1}\n{\"id\":2}\n",
} {
items, err := scanJSONLines([]byte(input))
if err != nil {
t.Fatalf("scanJSONLines(%q): %v", input, err)
}
if len(items) != 2 {
t.Fatalf("scanJSONLines(%q) = %d items, want 2", input, len(items))
}
}
}
func TestCMSPatchAndEdgeCreateUseNativeEndpoints(t *testing.T) {
var methods, paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
methods = append(methods, r.Method)
paths = append(paths, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodPost {
_, _ = io.WriteString(w, `{"id":"edge-1"}`)
return
}
_, _ = io.WriteString(w, `{"id":"node-1","data":{"title":"merged"}}`)
}))
defer server.Close()
client := newClient(Config{BaseURL: server.URL}, "token", "", time.Second)
var output bytes.Buffer
if err := runCMSNodes(context.Background(), client, []string{"patch", "node-1", "--input", "-"}, strings.NewReader(`{"data":{"title":"merged"}}`), &output); err != nil {
t.Fatalf("patch: %v", err)
}
if err := runCMSEdges(context.Background(), client, []string{"create", "--from", "page-1", "--to", "node-1"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("edge create: %v", err)
}
if strings.Join(methods, ",") != "PATCH,POST" {
t.Fatalf("methods = %v", methods)
}
if strings.Join(paths, ",") != "/api/nodes/node-1,/api/edges" {
t.Fatalf("paths = %v", paths)
}
}
func TestFinderSearchPassesFiltersAndStreamItemsUsesPipe(t *testing.T) {
var searchRequest map[string]any
var streamBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/find/api/stream":
if err := json.NewDecoder(r.Body).Decode(&searchRequest); err != nil {
t.Errorf("decode search request: %v", err)
}
_, _ = io.WriteString(w, `{"totalHits":1}`)
case "/find/api/stream-items":
data, _ := io.ReadAll(r.Body)
streamBody = string(data)
_, _ = io.WriteString(w, `{"id":42}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := newClient(Config{BaseURL: server.URL, FinderURL: server.URL + "/find"}, "token", "", time.Second)
var output bytes.Buffer
if err := runFinder(context.Background(), client, []string{"search", "--q", "drill", "--filters", `{"string":[{"id":"brand","value":["Dala"]}]}`, "--stock", "se", "--disable-grouping"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("finder search: %v", err)
}
if searchRequest["query"] != "drill" || searchRequest["disableGrouping"] != true {
t.Fatalf("search request = %#v", searchRequest)
}
if _, ok := searchRequest["string"]; !ok {
t.Fatalf("search request omitted string filters: %#v", searchRequest)
}
if err := runFinder(context.Background(), client, []string{"stream-items", "-"}, strings.NewReader("41\n42\n"), &output); err != nil {
t.Fatalf("stream items: %v", err)
}
if streamBody != "41\n42\n" {
t.Fatalf("stream body = %q", streamBody)
}
}
func TestCMSReadCommandsUseBackofficeBaseURL(t *testing.T) {
var paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{}`)
}))
defer server.Close()
client := newClient(Config{BaseURL: server.URL, FinderURL: "http://reader.invalid"}, "token", "", time.Second)
var output bytes.Buffer
if err := runCMSSchemas(context.Background(), client, []string{"get", "page"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("schema get: %v", err)
}
if err := runCMSTree(context.Background(), client, []string{"root", "--depth", "2"}, &output); err != nil {
t.Fatalf("tree: %v", err)
}
if strings.Join(paths, ",") != "/api/schemas/page,/api/tree/root" {
t.Fatalf("paths = %v", paths)
}
}
func TestFinderAdminAlwaysUsesBackofficeBaseURL(t *testing.T) {
var methods, paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
methods = append(methods, r.Method)
paths = append(paths, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"status":"ok"}`)
}))
defer server.Close()
client := newClient(Config{BaseURL: server.URL, FinderURL: "http://reader.invalid"}, "token", "", time.Second)
var output bytes.Buffer
if err := runFinder(context.Background(), client, []string{"admin", "settings", "get"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("admin settings: %v", err)
}
if err := runFinder(context.Background(), client, []string{"admin", "facets", "delete", "brand", "--yes"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("admin facet delete: %v", err)
}
if strings.Join(methods, ",") != "GET,DELETE" || strings.Join(paths, ",") != "/catalog/admin/settings,/catalog/admin/facets/brand" {
t.Fatalf("requests = %v %v", methods, paths)
}
}
+434
View File
@@ -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
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
const (
defaultBaseURL = "http://localhost:8080"
configEnv = "CMSCTL_CONFIG"
)
// Config is deliberately small and JSON-compatible so operators can inspect
// or move it between machines. The file is written with mode 0600.
type Config struct {
BaseURL string `json:"base_url,omitempty"`
FinderURL string `json:"finder_url,omitempty"`
AccessToken string `json:"access_token,omitempty"`
TokenType string `json:"token_type,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
}
func defaultConfig() Config {
return Config{BaseURL: defaultBaseURL, TokenType: "Bearer"}
}
func configPath(override string) (string, error) {
if override != "" {
return override, nil
}
if p := os.Getenv(configEnv); p != "" {
return p, nil
}
// UserConfigDir follows the platform convention: $XDG_CONFIG_HOME or
// ~/.config on Unix, %AppData% on Windows, and ~/Library/Application
// Support on macOS. Keep the explicit XDG check for callers that set it
// in environments where UserConfigDir cannot resolve it yet.
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
return filepath.Join(dir, "cmsctl", "config.json"), nil
}
if dir, err := os.UserConfigDir(); err == nil && dir != "" {
return filepath.Join(dir, "cmsctl", "config.json"), nil
}
// Last-resort Unix-compatible fallback for minimal environments without a
// discoverable platform config directory.
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("find user config directory: %w", err)
}
return filepath.Join(home, ".config", "cmsctl", "config.json"), nil
}
func loadConfig(path string) (Config, error) {
cfg := defaultConfig()
body, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return cfg, nil
}
if err != nil {
return Config{}, fmt.Errorf("read config %s: %w", path, err)
}
if err := json.Unmarshal(body, &cfg); err != nil {
return Config{}, fmt.Errorf("parse config %s: %w", path, err)
}
if cfg.BaseURL == "" {
cfg.BaseURL = defaultBaseURL
}
if cfg.TokenType == "" {
cfg.TokenType = "Bearer"
}
return cfg, nil
}
func saveConfig(path string, cfg Config) error {
if cfg.BaseURL == "" {
cfg.BaseURL = defaultBaseURL
}
if cfg.TokenType == "" {
cfg.TokenType = "Bearer"
}
body, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode config: %w", err)
}
body = append(body, '\n')
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create config directory: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".config-*")
if err != nil {
return fmt.Errorf("create config temp file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
return fmt.Errorf("protect config temp file: %w", err)
}
if _, err := tmp.Write(body); err != nil {
tmp.Close()
return fmt.Errorf("write config: %w", err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("sync config: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close config: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("replace config: %w", err)
}
return nil
}
func clearToken(cfg *Config) {
cfg.AccessToken = ""
cfg.TokenType = "Bearer"
cfg.ExpiresAt = time.Time{}
}
func effectiveBaseURL(cfg Config) string {
base := strings.TrimRight(cfg.BaseURL, "/")
if base == "" {
return defaultBaseURL
}
return base
}
func effectiveFinderURL(cfg Config) string {
if cfg.FinderURL != "" {
return strings.TrimRight(cfg.FinderURL, "/")
}
return effectiveBaseURL(cfg) + "/find"
}
+750
View File
@@ -0,0 +1,750 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/url"
"strconv"
"strings"
)
func requireID(args []string, usage string) (string, error) {
if len(args) == 0 || args[0] == "" || strings.HasPrefix(args[0], "-") {
return "", errors.New(usage)
}
return args[0], nil
}
func readRequiredBody(stdin io.Reader, args []string, label string) ([]byte, error) {
body, err := readInput(stdin, inputFlag(args), positionalWithoutInput(args))
if err != nil {
return nil, fmt.Errorf("read %s: %w", label, err)
}
if len(body) == 0 {
return nil, fmt.Errorf("%s requires JSON input or a file", label)
}
return body, nil
}
func cmsPatchNode(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error {
id, err := requireID(args, "cms nodes patch requires a node id")
if err != nil {
return err
}
body, err := readRequiredBody(stdin, args[1:], "node patch")
if err != nil {
return err
}
if !json.Valid(body) {
return errors.New("node patch input is not valid JSON")
}
response, err := client.patchBody(ctx, client.cmsURL("/nodes/"+escapePathSegment(id)), body, "application/json")
if err != nil {
return err
}
return writeRaw(stdout, response)
}
func cmsDeleteNode(ctx context.Context, client *Client, args []string, stdout io.Writer) error {
id, err := requireID(args, "cms nodes delete requires a node id")
if err != nil {
return err
}
fs := flag.NewFlagSet("cms nodes delete", flag.ContinueOnError)
confirmed := fs.Bool("yes", false, "confirm deletion")
fs.SetOutput(io.Discard)
if err := fs.Parse(args[1:]); err != nil {
return err
}
if !*confirmed {
return errors.New("cms nodes delete requires --yes")
}
body, err := client.delete(ctx, client.cmsURL("/nodes/"+escapePathSegment(id)))
if err != nil {
return err
}
return writeRaw(stdout, body)
}
func cmsRetypeNode(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error {
id, err := requireID(args, "cms nodes retype requires a node id")
if err != nil {
return err
}
fs := flag.NewFlagSet("cms nodes retype", flag.ContinueOnError)
dataType := fs.String("type", "", "new node type")
input := fs.String("input", "", "complete request JSON file or - for stdin")
data := fs.String("data", "", "replacement JSON data object, @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 retype: %w", err)
}
if len(body) == 0 {
if *dataType == "" {
return errors.New("cms nodes retype requires --type when --input is not used")
}
var dataObject map[string]any
if *data != "" {
dataBody, readErr := readJSONValue(stdin, *data)
if readErr != nil {
return readErr
}
if err := json.Unmarshal(dataBody, &dataObject); err != nil {
return fmt.Errorf("--data must be a JSON object: %w", err)
}
}
body, err = json.Marshal(map[string]any{"dataType": *dataType, "data": dataObject})
if err != nil {
return err
}
}
if !json.Valid(body) {
return errors.New("node retype input is not valid JSON")
}
response, err := client.postBody(ctx, client.cmsURL("/nodes/"+escapePathSegment(id)+"/retype"), body, "application/json")
if err != nil {
return err
}
return writeRaw(stdout, response)
}
func runCMSEdges(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 edges get <node-id>\n cmsctl cms edges create [--from ID --to ID --type child --handle NAME --sort N]\n cmsctl cms edges delete <edge-id> --yes\n cmsctl cms edges reorder <parent-id> --order edge-id,...\n")
return nil
}
switch args[0] {
case "get":
id, err := requireID(args[1:], "cms edges get requires a node id")
if err != nil {
return err
}
body, err := client.get(ctx, client.cmsURL("/nodes/"+escapePathSegment(id)+"/edges"))
if err != nil {
return err
}
return writeRaw(stdout, body)
case "create":
fs := flag.NewFlagSet("cms edges create", flag.ContinueOnError)
from := fs.String("from", "", "source node id")
to := fs.String("to", "", "target node id")
typ := fs.String("type", "child", "edge type")
handle := fs.String("handle", "", "connection handle")
sort := fs.Int("sort", 0, "sibling sort position")
fs.SetOutput(io.Discard)
if err := fs.Parse(args[1:]); err != nil {
return err
}
body, err := readInput(stdin, inputFlag(args[1:]), fs.Args())
if err != nil {
return err
}
if len(body) == 0 {
if *from == "" || *to == "" {
return errors.New("cms edges create requires --from and --to, or --input")
}
body, err = json.Marshal(map[string]any{"from": *from, "to": *to, "type": *typ, "handle": *handle, "sort": *sort})
if err != nil {
return err
}
}
if !json.Valid(body) {
return errors.New("edge input is not valid JSON")
}
response, err := client.postBody(ctx, client.cmsURL("/edges"), body, "application/json")
if err != nil {
return err
}
return writeRaw(stdout, response)
case "delete":
id, err := requireID(args[1:], "cms edges delete requires an edge id")
if err != nil {
return err
}
fs := flag.NewFlagSet("cms edges delete", flag.ContinueOnError)
confirmed := fs.Bool("yes", false, "confirm deletion")
fs.SetOutput(io.Discard)
if err := fs.Parse(args[2:]); err != nil {
return err
}
if !*confirmed {
return errors.New("cms edges delete requires --yes")
}
body, err := client.delete(ctx, client.cmsURL("/edges/"+escapePathSegment(id)))
if err != nil {
return err
}
return writeRaw(stdout, body)
case "reorder":
parent, err := requireID(args[1:], "cms edges reorder requires a parent node id")
if err != nil {
return err
}
fs := flag.NewFlagSet("cms edges reorder", flag.ContinueOnError)
order := fs.String("order", "", "comma-separated edge ids in the desired order")
input := fs.String("input", "", "complete request JSON file or - for stdin")
fs.SetOutput(io.Discard)
if err := fs.Parse(args[2:]); err != nil {
return err
}
body, err := readInput(stdin, *input, fs.Args())
if err != nil {
return err
}
if len(body) == 0 {
if *order == "" {
return errors.New("cms edges reorder requires --order or --input")
}
body, err = json.Marshal(map[string]any{"parent": parent, "order": splitCSV(*order)})
if err != nil {
return err
}
}
if !json.Valid(body) {
return errors.New("edge reorder input is not valid JSON")
}
response, err := client.postBody(ctx, client.cmsURL("/edges/reorder"), body, "application/json")
if err != nil {
return err
}
return writeRaw(stdout, response)
default:
return fmt.Errorf("unknown cms edges command %q", args[0])
}
}
func runCMSSchemas(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error {
if len(args) == 0 || args[0] == "list" {
body, err := client.get(ctx, client.cmsURL("/schemas"))
if err != nil {
return err
}
return writeRaw(stdout, body)
}
typ, err := requireID(args[1:], "cms schemas requires a schema type")
if err != nil {
return err
}
switch args[0] {
case "get":
body, err := client.get(ctx, client.cmsURL("/schemas/"+escapePathSegment(typ)))
if err != nil {
return err
}
return writeRaw(stdout, body)
case "put":
body, err := readRequiredBody(stdin, args[2:], "schema")
if err != nil {
return err
}
if !json.Valid(body) {
return errors.New("schema input is not valid JSON")
}
response, err := client.putBody(ctx, client.cmsURL("/schemas/"+escapePathSegment(typ)), body, "application/json")
if err != nil {
return err
}
return writeRaw(stdout, response)
case "delete":
fs := flag.NewFlagSet("cms schemas delete", flag.ContinueOnError)
confirmed := fs.Bool("yes", false, "confirm deletion")
fs.SetOutput(io.Discard)
if err := fs.Parse(args[2:]); err != nil {
return err
}
if !*confirmed {
return errors.New("cms schemas delete requires --yes")
}
body, err := client.delete(ctx, client.cmsURL("/schemas/"+escapePathSegment(typ)))
if err != nil {
return err
}
return writeRaw(stdout, body)
default:
return fmt.Errorf("unknown cms schemas command %q", args[0])
}
}
func runCMSTemplates(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error {
if len(args) == 0 || args[0] == "list" {
body, err := client.get(ctx, client.cmsURL("/templates"))
if err != nil {
return err
}
return writeRaw(stdout, body)
}
name, err := requireID(args[1:], "cms templates requires a template name")
if err != nil {
return err
}
switch args[0] {
case "get":
body, err := client.get(ctx, client.cmsURL("/templates/"+escapePathSegment(name)))
if err != nil {
return err
}
return writeRaw(stdout, body)
case "put":
body, err := readRequiredBody(stdin, args[2:], "template")
if err != nil {
return err
}
response, err := client.putBody(ctx, client.cmsURL("/templates/"+escapePathSegment(name)), body, "text/plain; charset=utf-8")
if err != nil {
return err
}
return writeRaw(stdout, response)
case "delete":
fs := flag.NewFlagSet("cms templates delete", flag.ContinueOnError)
confirmed := fs.Bool("yes", false, "confirm deletion")
fs.SetOutput(io.Discard)
if err := fs.Parse(args[2:]); err != nil {
return err
}
if !*confirmed {
return errors.New("cms templates delete requires --yes")
}
body, err := client.delete(ctx, client.cmsURL("/templates/"+escapePathSegment(name)))
if err != nil {
return err
}
return writeRaw(stdout, body)
default:
return fmt.Errorf("unknown cms templates command %q", args[0])
}
}
func runCMSBlueprints(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error {
if len(args) == 0 || args[0] == "list" {
body, err := client.get(ctx, client.cmsURL("/blueprints"))
if err != nil {
return err
}
return writeRaw(stdout, body)
}
if args[0] != "create" {
return fmt.Errorf("unknown cms blueprints command %q", args[0])
}
fs := flag.NewFlagSet("cms blueprints create", flag.ContinueOnError)
blueprintID := fs.String("blueprint", "", "blueprint id")
name := fs.String("name", "", "created node name")
pathValue := fs.String("path", "", "created node path")
input := fs.String("input", "", "complete request 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 err
}
if len(body) == 0 {
if *blueprintID == "" {
return errors.New("cms blueprints create requires --blueprint or --input")
}
body, err = json.Marshal(map[string]string{"blueprintId": *blueprintID, "name": *name, "path": *pathValue})
if err != nil {
return err
}
}
if !json.Valid(body) {
return errors.New("blueprint input is not valid JSON")
}
response, err := client.postBody(ctx, client.cmsURL("/nodes/create-from-blueprint"), body, "application/json")
if err != nil {
return err
}
return writeRaw(stdout, response)
}
func runCMSTree(ctx context.Context, client *Client, args []string, stdout io.Writer) error {
id, err := requireID(args, "cms tree requires a root node id")
if err != nil {
return err
}
fs := flag.NewFlagSet("cms tree", flag.ContinueOnError)
depth := fs.Int("depth", -1, "tree depth; -1 is unlimited")
fs.SetOutput(io.Discard)
if err := fs.Parse(args[1:]); err != nil {
return err
}
query := url.Values{"depth": {strconv.Itoa(*depth)}}
body, err := client.get(ctx, client.cmsURL("/tree/"+escapePathSegment(id))+"?"+query.Encode())
if err != nil {
return err
}
return writeRaw(stdout, body)
}
func runCMSNav(ctx context.Context, client *Client, args []string, stdout io.Writer) error {
fs := flag.NewFlagSet("cms nav", flag.ContinueOnError)
typ := fs.String("type", "", "navigation edge type, default tree")
dataType := fs.String("data-type", "", "restrict forest to a data type")
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return err
}
pathValue := "/nav"
if len(fs.Args()) > 0 {
pathValue += "/" + escapePathSegment(fs.Args()[0])
}
query := url.Values{}
if *typ != "" {
query.Set("type", *typ)
}
if *dataType != "" {
query.Set("dataType", *dataType)
}
endpoint := client.cmsURL(pathValue)
if len(query) > 0 {
endpoint += "?" + query.Encode()
}
body, err := client.get(ctx, endpoint)
if err != nil {
return err
}
return writeRaw(stdout, body)
}
func runCMSGraph(ctx context.Context, client *Client, args []string, stdout io.Writer) error {
fs := flag.NewFlagSet("cms graph", flag.ContinueOnError)
root := fs.String("root", "", "root node id")
budget := fs.Int("budget", 0, "maximum node count")
edges := fs.String("edges", "", "comma-separated edge types")
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return err
}
query := url.Values{}
if *root != "" {
query.Set("root", *root)
}
if *budget > 0 {
query.Set("budget", strconv.Itoa(*budget))
}
if *edges != "" {
query["edges"] = splitCSV(*edges)
}
endpoint := client.cmsURL("/graph")
if len(query) > 0 {
endpoint += "?" + query.Encode()
}
body, err := client.get(ctx, endpoint)
if err != nil {
return err
}
return writeRaw(stdout, body)
}
func runCMSPublish(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error {
body, err := readInput(stdin, inputFlag(args), positionalWithoutInput(args))
if err != nil {
return err
}
if len(body) == 0 {
ids := positionalWithoutInput(args)
if len(ids) == 0 {
return errors.New("cms publish requires root ids or --input")
}
body, err = json.Marshal(map[string]any{"ids": ids})
if err != nil {
return err
}
}
if !json.Valid(body) {
return errors.New("publish input is not valid JSON")
}
response, err := client.postBody(ctx, client.cmsURL("/publish-roots"), body, "application/json")
if err != nil {
return err
}
return writeRaw(stdout, response)
}
func runCMSRegenerate(ctx context.Context, client *Client, args []string, stdout io.Writer) error {
fs := flag.NewFlagSet("cms regenerate", flag.ContinueOnError)
typ := fs.String("type", "", "only regenerate roots affected by this node type")
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return err
}
endpoint := client.cmsURL("/regenerate")
if *typ != "" {
endpoint += "?type=" + url.QueryEscape(*typ)
}
body, err := client.postBody(ctx, endpoint, nil, "")
if err != nil {
return err
}
return writeRaw(stdout, body)
}
func runCMSScript(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error {
fs := flag.NewFlagSet("cms script", flag.ContinueOnError)
script := fs.String("script", "", "JavaScript source")
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 err
}
if len(body) == 0 {
if *script == "" {
return errors.New("cms script requires --script or --input")
}
body, err = json.Marshal(map[string]string{"script": *script})
if err != nil {
return err
}
}
if !json.Valid(body) {
return errors.New("script input is not valid JSON")
}
response, err := client.postBody(ctx, client.cmsURL("/scripts/run"), body, "application/json")
if err != nil {
return err
}
return writeRaw(stdout, response)
}
func runFinderFacets(ctx context.Context, client *Client, args []string, stdout io.Writer) error {
fs := flag.NewFlagSet("finder facets", flag.ContinueOnError)
q := fs.String("q", "", "search query")
filters := fs.String("filters", "", "JSON-encoded filters")
resultKey := fs.String("result-key", "", "cached search result key")
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return err
}
query := url.Values{}
if *q != "" {
query.Set("q", *q)
}
if *filters != "" {
query.Set("filters", *filters)
}
if *resultKey != "" {
query.Set("resultKey", *resultKey)
}
return finderGetQuery(ctx, client, "facets", query, stdout)
}
func runFinderConfig(ctx context.Context, client *Client, args []string, stdout io.Writer) error {
fs := flag.NewFlagSet("finder config", flag.ContinueOnError)
filters := fs.String("filters", "", "JSON-encoded filters")
stock := fs.String("stock", "", "stock filter")
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return err
}
query := url.Values{}
if *filters != "" {
query.Set("filters", *filters)
}
if *stock != "" {
query.Set("stock", *stock)
}
return finderGetQuery(ctx, client, "config", query, stdout)
}
func runFinderRelated(ctx context.Context, client *Client, endpoint string, args []string, stdout io.Writer) error {
id, err := requireID(args, "finder "+endpoint+" requires an item id or SKU")
if err != nil {
return err
}
return finderSimpleGet(ctx, client, []string{id}, endpoint, stdout)
}
func runFinderCompatible(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error {
id, err := requireID(args, "finder compatible requires an item id or SKU")
if err != nil {
return err
}
fs := flag.NewFlagSet("finder compatible", flag.ContinueOnError)
limit := fs.Int("limit", 60, "maximum results")
input := fs.String("cart-input", "", "JSON array of cart item IDs, or - for stdin")
fs.SetOutput(io.Discard)
if err := fs.Parse(args[1:]); err != nil {
return err
}
query := url.Values{"limit": {strconv.Itoa(*limit)}}
endpoint := client.finderURL("/compatible/"+escapePathSegment(id)) + "?" + query.Encode()
if *input == "" {
body, err := client.get(ctx, endpoint)
if err != nil {
return err
}
return writeRaw(stdout, body)
}
body, err := readInput(stdin, *input, nil)
if err != nil {
return err
}
if !json.Valid(body) {
return errors.New("compatible cart input is not valid JSON")
}
response, err := client.postBody(ctx, endpoint, body, "application/json")
if err != nil {
return err
}
return writeRaw(stdout, response)
}
func runFinderStreamItems(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error {
body, err := readRequiredBody(stdin, args, "finder stream-items")
if err != nil {
return err
}
var ids []string
for _, line := range strings.Split(string(body), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if _, err := strconv.ParseUint(line, 10, 32); err != nil {
return fmt.Errorf("finder stream-items: invalid item id %q", line)
}
ids = append(ids, line)
}
if len(ids) == 0 {
return errors.New("finder stream-items input contains no item ids")
}
response, err := client.postBody(ctx, client.finderURL("/stream-items"), []byte(strings.Join(ids, "\n")+"\n"), "text/plain")
if err != nil {
return err
}
return writeRaw(stdout, response)
}
func runFinderSaveTrigger(ctx context.Context, client *Client, stdout io.Writer) error {
body, err := client.postBody(ctx, client.finderURL("/save-trigger"), nil, "")
if err != nil {
return err
}
return writeRaw(stdout, body)
}
func runFinderAdmin(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 admin settings {get|put}\n cmsctl finder admin facets {list|create|update|delete}\n cmsctl finder admin update-by-filter [file|-]\n cmsctl finder admin delete-by-filter [file|-] --yes\n")
return nil
}
switch args[0] {
case "settings":
if len(args) < 2 || args[1] == "get" {
body, err := client.get(ctx, client.finderAdminURL("/settings"))
if err != nil {
return err
}
return writeRaw(stdout, body)
}
if args[1] != "put" {
return fmt.Errorf("unknown finder admin settings command %q", args[1])
}
body, err := readRequiredBody(stdin, args[2:], "finder settings")
if err != nil {
return err
}
if !json.Valid(body) {
return errors.New("finder settings input is not valid JSON")
}
response, err := client.putBody(ctx, client.finderAdminURL("/settings"), body, "application/json")
if err != nil {
return err
}
return writeRaw(stdout, response)
case "facets":
if len(args) < 2 || args[1] == "list" {
body, err := client.get(ctx, client.finderAdminURL("/facets"))
if err != nil {
return err
}
return writeRaw(stdout, body)
}
id, err := requireID(args[2:], "finder admin facets requires a facet id")
if err != nil && args[1] != "create" {
return err
}
switch args[1] {
case "create":
body, readErr := readRequiredBody(stdin, args[2:], "finder facet")
if readErr != nil {
return readErr
}
response, reqErr := client.postBody(ctx, client.finderAdminURL("/facets"), body, "application/json")
if reqErr != nil {
return reqErr
}
return writeRaw(stdout, response)
case "update":
body, readErr := readRequiredBody(stdin, args[3:], "finder facet")
if readErr != nil {
return readErr
}
response, reqErr := client.putBody(ctx, client.finderAdminURL("/facets/"+escapePathSegment(id)), body, "application/json")
if reqErr != nil {
return reqErr
}
return writeRaw(stdout, response)
case "delete":
fs := flag.NewFlagSet("finder admin facets delete", flag.ContinueOnError)
confirmed := fs.Bool("yes", false, "confirm deletion")
fs.SetOutput(io.Discard)
if err := fs.Parse(args[3:]); err != nil {
return err
}
if !*confirmed {
return errors.New("finder admin facets delete requires --yes")
}
body, reqErr := client.delete(ctx, client.finderAdminURL("/facets/"+escapePathSegment(id)))
if reqErr != nil {
return reqErr
}
return writeRaw(stdout, body)
default:
return fmt.Errorf("unknown finder admin facets command %q", args[1])
}
case "update-by-filter", "delete-by-filter":
body, err := readRequiredBody(stdin, args[1:], "finder "+args[0])
if err != nil {
return err
}
if !json.Valid(body) {
return errors.New("finder filter input is not valid JSON")
}
if args[0] == "delete-by-filter" {
fs := flag.NewFlagSet("finder admin delete-by-filter", flag.ContinueOnError)
confirmed := fs.Bool("yes", false, "confirm deletion")
fs.SetOutput(io.Discard)
if err := fs.Parse(args[1:]); err != nil {
return err
}
if !*confirmed {
return errors.New("finder admin delete-by-filter requires --yes")
}
response, reqErr := client.deleteBody(ctx, client.finderAdminURL("/items/by-filter"), body, "application/json")
if reqErr != nil {
return reqErr
}
return writeRaw(stdout, response)
}
response, err := client.postBody(ctx, client.finderAdminURL("/items/batch/by-filter"), body, "application/json")
if err != nil {
return err
}
return writeRaw(stdout, response)
default:
return fmt.Errorf("unknown finder admin command %q", args[0])
}
}
+406
View File
@@ -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
}