Files
platform/cmd/cmsctl
mats a5d00c9d7e cmsctl: persist a scoped API key for script auth
Add `auth api-key {set|clear}` and prefer a stored key over the device
token, so scripts authenticate with a permanent minted API key
(X-API-Key) instead of re-running the 12h device flow. Explicit flags
and env still win over persisted credentials. Also treat a zero-byte
config file as defaults (previously every command failed to parse), and
correct AGENTS.md, which claimed device tokens auto-refresh.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-08-29 14:52:50 +02:00
..
2026-08-22 10:12:22 +02:00
2026-08-22 10:12:22 +02:00
2026-08-22 10:12:22 +02:00

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/:

go build -o cmsctl ./cmd/cmsctl
go run ./cmd/cmsctl --help

The default backoffice URL is http://localhost:8080.

Authenticate

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:

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.

Todos and suggestions

List, filter, and complete improvement notes filed via the Suggest button or API:

cmsctl todos list                              # all todos in triage sort (open first)
cmsctl todos list --open                       # open / uncompleted only (--done=false)
cmsctl todos list --done                       # completed only (--done=true)
cmsctl todos list --prio high --q "checkout"   # filter by priority and search text
cmsctl todos done NODE_ID                      # mark completed (sets data.done = true)
cmsctl todos reopen NODE_ID                    # reopen (sets data.done = false)
cmsctl todos get NODE_ID                       # get todo node
cmsctl todos create --note "Fix cart layout" --prio high --location "Commerce · Cart"
cmsctl todos delete NODE_ID --yes

CMS

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:

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:

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:

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:

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:

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.

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:

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:

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:

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:

{"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.