feat(platform): add cmsctl and document newsletter and shell updates
Generated with Codebuff 🤖 Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
1 parent
0f8ed97878
commit
fea6eb51b1
7 files changed
+2408
No files matched your search
@@ -0,0 +1,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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const usageText = `cmsctl — CMS and finder API CLI
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cmsctl auth login [--scope read,write] Device-login and persist token
|
||||||
|
cmsctl auth status Show current credential status
|
||||||
|
cmsctl auth token [--raw] Print the persisted token
|
||||||
|
cmsctl auth logout Remove the persisted token
|
||||||
|
|
||||||
|
cmsctl cms nodes list [flags] List CMS nodes
|
||||||
|
cmsctl cms nodes get <id> Get one node
|
||||||
|
cmsctl cms nodes create [flags] Create a node
|
||||||
|
cmsctl cms nodes update <id> [flags] Replace a node
|
||||||
|
cmsctl cms nodes patch <id> [flags] Merge fields into a node
|
||||||
|
cmsctl cms nodes delete <id> --yes Delete a node
|
||||||
|
cmsctl cms nodes retype <id> --type TYPE Change node type
|
||||||
|
cmsctl cms edges {get|create|delete|reorder} Manage graph edges
|
||||||
|
cmsctl cms schemas {list|get|put|delete} Manage JSON Schemas
|
||||||
|
cmsctl cms templates {list|get|put|delete} Manage render templates
|
||||||
|
cmsctl cms blueprints {list|create} Inspect/apply blueprints
|
||||||
|
cmsctl cms tree <root-id> Read a composition tree
|
||||||
|
cmsctl cms nav [root-id] Read navigation hierarchy
|
||||||
|
cmsctl cms graph [flags] Read a graph region
|
||||||
|
cmsctl cms publish <root-id>... Publish roots
|
||||||
|
cmsctl cms regenerate [--type TYPE] Regenerate static output
|
||||||
|
cmsctl cms script --script '...' Run a CMS script
|
||||||
|
cmsctl cms search --q <text> Search CMS nodes
|
||||||
|
cmsctl cms types List registered node types
|
||||||
|
cmsctl cms import [file|-] Import nodes and edges
|
||||||
|
|
||||||
|
cmsctl finder search [flags] Search catalog (JSONL output)
|
||||||
|
cmsctl finder get <id-or-sku> Get one catalog item
|
||||||
|
cmsctl finder config [flags] Browse items and facets
|
||||||
|
cmsctl finder facets [flags] List query facets
|
||||||
|
cmsctl finder facet-list List configured facets
|
||||||
|
cmsctl finder values <facet-id> List values for a facet
|
||||||
|
cmsctl finder stream-items [file|-] Stream items by numeric IDs
|
||||||
|
cmsctl finder suggest --q <text> Catalog suggestions
|
||||||
|
cmsctl finder popular Popular catalog items
|
||||||
|
cmsctl finder related <id> Related catalog items
|
||||||
|
cmsctl finder compatible <id> Compatible catalog items
|
||||||
|
cmsctl finder similar <id> Visually similar items
|
||||||
|
cmsctl finder relation-groups|facet-groups Read finder configuration
|
||||||
|
cmsctl finder save-trigger Ask reader to persist state
|
||||||
|
cmsctl finder import [file|-] Import inventory items (JSON/JSONL)
|
||||||
|
|
||||||
|
All commands accept --config, --base-url, --finder-url, --api-key, --token,
|
||||||
|
and --timeout before the command. A missing --finder-url uses <base-url>/find
|
||||||
|
for reads. Finder administration always uses <base-url>/catalog/admin.
|
||||||
|
|
||||||
|
Pipe examples:
|
||||||
|
cat nodes.json | cmsctl cms import -
|
||||||
|
jq '{data: .data, tags: .tags}' node.json | cmsctl cms nodes update ID --input -
|
||||||
|
cat inventory.jsonl | cmsctl finder import -
|
||||||
|
cmsctl finder search --q drill --limit 20 | jq -c .
|
||||||
|
`
|
||||||
|
|
||||||
|
type globalOptions struct {
|
||||||
|
configPath string
|
||||||
|
baseURL string
|
||||||
|
finderURL string
|
||||||
|
apiKey string
|
||||||
|
token string
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "cmsctl:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args []string, stdout, stderr io.Writer) error {
|
||||||
|
return runWithStdin(args, os.Stdin, stdout, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
|
||||||
|
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
|
||||||
|
_, _ = io.WriteString(stdout, usageText)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
global, rest, err := parseGlobal(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(rest) == 0 {
|
||||||
|
_, _ = io.WriteString(stdout, usageText)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgPath, err := configPath(global.configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg, err := loadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if global.baseURL != "" {
|
||||||
|
cfg.BaseURL = global.baseURL
|
||||||
|
}
|
||||||
|
if global.finderURL != "" {
|
||||||
|
cfg.FinderURL = global.finderURL
|
||||||
|
}
|
||||||
|
if global.token == "" {
|
||||||
|
global.token = os.Getenv("CMSCTL_TOKEN")
|
||||||
|
}
|
||||||
|
if global.apiKey == "" {
|
||||||
|
global.apiKey = os.Getenv("CMSCTL_API_KEY")
|
||||||
|
}
|
||||||
|
if global.token == "" && global.apiKey == "" {
|
||||||
|
global.token = cfg.AccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
switch rest[0] {
|
||||||
|
case "auth":
|
||||||
|
return runAuth(ctx, cfgPath, &cfg, rest[1:], stdout, stderr, global.timeout)
|
||||||
|
case "cms":
|
||||||
|
client := newClient(cfg, global.token, global.apiKey, global.timeout)
|
||||||
|
return runCMS(ctx, client, rest[1:], stdin, stdout)
|
||||||
|
case "finder", "inventory":
|
||||||
|
client := newClient(cfg, global.token, global.apiKey, global.timeout)
|
||||||
|
return runFinder(ctx, client, rest[1:], stdin, stdout)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown command %q (use --help)", rest[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGlobal(args []string) (globalOptions, []string, error) {
|
||||||
|
g := globalOptions{timeout: 30 * time.Second}
|
||||||
|
for len(args) > 0 {
|
||||||
|
arg := args[0]
|
||||||
|
if arg == "--" {
|
||||||
|
return g, args[1:], nil
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(arg, "-") {
|
||||||
|
return g, args, nil
|
||||||
|
}
|
||||||
|
args = args[1:]
|
||||||
|
name, value, hasValue := strings.Cut(arg, "=")
|
||||||
|
if !hasValue {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return g, nil, fmt.Errorf("%s requires a value", name)
|
||||||
|
}
|
||||||
|
value, args = args[0], args[1:]
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case "--config":
|
||||||
|
g.configPath = value
|
||||||
|
case "--base-url":
|
||||||
|
g.baseURL = value
|
||||||
|
case "--finder-url":
|
||||||
|
g.finderURL = value
|
||||||
|
case "--api-key":
|
||||||
|
g.apiKey = value
|
||||||
|
case "--token":
|
||||||
|
g.token = value
|
||||||
|
case "--timeout":
|
||||||
|
d, err := time.ParseDuration(value)
|
||||||
|
if err != nil {
|
||||||
|
return g, nil, fmt.Errorf("invalid --timeout %q: %w", value, err)
|
||||||
|
}
|
||||||
|
g.timeout = d
|
||||||
|
default:
|
||||||
|
return g, nil, fmt.Errorf("unknown global flag %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return g, args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAuth(ctx context.Context, cfgPath string, cfg *Config, args []string, stdout, stderr io.Writer, timeout time.Duration) error {
|
||||||
|
if len(args) == 0 || args[0] == "help" || args[0] == "--help" {
|
||||||
|
_, _ = io.WriteString(stdout, "Usage: cmsctl auth login [--scope read,write] [--no-poll]\n cmsctl auth status\n cmsctl auth token [--raw]\n cmsctl auth logout\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "login":
|
||||||
|
fs := flag.NewFlagSet("auth login", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
scopes := fs.String("scope", "read,write", "comma-separated requested scopes")
|
||||||
|
noPoll := fs.Bool("no-poll", false, "print the grant without polling for approval")
|
||||||
|
if err := fs.Parse(args[1:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
requested := splitCSV(*scopes)
|
||||||
|
client := newClient(*cfg, "", "", timeout)
|
||||||
|
endpoint := effectiveBaseURL(*cfg) + "/api/auth/device"
|
||||||
|
if len(requested) > 0 {
|
||||||
|
endpoint += "?scope=" + strings.Join(escapeQueryValues(requested), ",")
|
||||||
|
}
|
||||||
|
body, err := client.postBody(ctx, endpoint, nil, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var grant struct {
|
||||||
|
DeviceCode string `json:"device_code"`
|
||||||
|
UserCode string `json:"user_code"`
|
||||||
|
VerificationURI string `json:"verification_uri"`
|
||||||
|
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &grant); err != nil {
|
||||||
|
return fmt.Errorf("decode device grant: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stderr, "Open %s\nCode: %s\n", grant.VerificationURIComplete, grant.UserCode)
|
||||||
|
if *noPoll {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if grant.DeviceCode == "" {
|
||||||
|
return errors.New("device grant did not include device_code")
|
||||||
|
}
|
||||||
|
interval := time.Duration(grant.Interval) * time.Second
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 5 * time.Second
|
||||||
|
}
|
||||||
|
deadline := time.NewTimer(time.Duration(grant.ExpiresIn) * time.Second)
|
||||||
|
defer deadline.Stop()
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-deadline.C:
|
||||||
|
return errors.New("device login expired before approval")
|
||||||
|
case <-ticker.C:
|
||||||
|
pollBody, pollErr := client.postJSON(ctx, effectiveBaseURL(*cfg)+"/api/auth/device/token", map[string]string{
|
||||||
|
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
"device_code": grant.DeviceCode,
|
||||||
|
})
|
||||||
|
if pollErr != nil {
|
||||||
|
var httpErr *HTTPError
|
||||||
|
if errors.As(pollErr, &httpErr) {
|
||||||
|
var pending struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(httpErr.Body, &pending) == nil && pending.Error == "authorization_pending" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pollErr
|
||||||
|
}
|
||||||
|
var token struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(pollBody, &token); err != nil {
|
||||||
|
return fmt.Errorf("decode access token: %w", err)
|
||||||
|
}
|
||||||
|
if token.AccessToken == "" {
|
||||||
|
return errors.New("device token response did not include access_token")
|
||||||
|
}
|
||||||
|
cfg.AccessToken = token.AccessToken
|
||||||
|
cfg.TokenType = token.TokenType
|
||||||
|
cfg.ExpiresAt = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
|
||||||
|
if err := saveConfig(cfgPath, *cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stderr, "Authenticated; token saved to %s\n", cfgPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "status":
|
||||||
|
status := map[string]any{
|
||||||
|
"config": cfgPath,
|
||||||
|
"base_url": effectiveBaseURL(*cfg),
|
||||||
|
"finder_url": effectiveFinderURL(*cfg),
|
||||||
|
"authenticated": cfg.AccessToken != "" && (cfg.ExpiresAt.IsZero() || time.Now().Before(cfg.ExpiresAt)),
|
||||||
|
}
|
||||||
|
if !cfg.ExpiresAt.IsZero() {
|
||||||
|
status["expires_at"] = cfg.ExpiresAt
|
||||||
|
}
|
||||||
|
return writeJSON(stdout, status)
|
||||||
|
case "token":
|
||||||
|
fs := flag.NewFlagSet("auth token", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
raw := fs.Bool("raw", false, "print only the token")
|
||||||
|
if err := fs.Parse(args[1:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.AccessToken == "" {
|
||||||
|
return errors.New("no persisted access token; run auth login")
|
||||||
|
}
|
||||||
|
if *raw {
|
||||||
|
_, err := fmt.Fprintln(stdout, cfg.AccessToken)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeJSON(stdout, map[string]any{"access_token": cfg.AccessToken, "token_type": cfg.TokenType, "expires_at": cfg.ExpiresAt})
|
||||||
|
case "logout":
|
||||||
|
clearToken(cfg)
|
||||||
|
if err := saveConfig(cfgPath, *cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, "Logged out; token removed from %s\n", cfgPath)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown auth command %q", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w io.Writer, value any) error {
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
return enc.Encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readInput(stdin io.Reader, inputPath string, positional []string) ([]byte, error) {
|
||||||
|
if inputPath == "" && len(positional) > 0 {
|
||||||
|
inputPath = positional[0]
|
||||||
|
}
|
||||||
|
if inputPath == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if inputPath == "-" {
|
||||||
|
return io.ReadAll(stdin)
|
||||||
|
}
|
||||||
|
return os.ReadFile(filepath.Clean(inputPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readJSONValue(stdin io.Reader, raw string) (json.RawMessage, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return nil, errors.New("JSON value is required")
|
||||||
|
}
|
||||||
|
if raw == "-" {
|
||||||
|
body, err := io.ReadAll(stdin)
|
||||||
|
return body, err
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(raw, "@") {
|
||||||
|
return os.ReadFile(filepath.Clean(strings.TrimPrefix(raw, "@")))
|
||||||
|
}
|
||||||
|
return []byte(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(raw string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, value := range strings.Split(raw, ",") {
|
||||||
|
if value = strings.TrimSpace(value); value != "" {
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeQueryValues(values []string) []string {
|
||||||
|
out := make([]string, len(values))
|
||||||
|
for i, value := range values {
|
||||||
|
out[i] = urlQueryEscape(value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlQueryEscape(value string) string {
|
||||||
|
return url.QueryEscape(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanJSONLines(body []byte) ([]json.RawMessage, error) {
|
||||||
|
var array []json.RawMessage
|
||||||
|
if json.Unmarshal(body, &array) == nil {
|
||||||
|
return array, nil
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(string(body)))
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), maxResponseBytes)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var raw json.RawMessage
|
||||||
|
if !json.Valid([]byte(line)) {
|
||||||
|
return nil, fmt.Errorf("invalid JSONL line %d", len(array)+1)
|
||||||
|
}
|
||||||
|
raw = append(raw, line...)
|
||||||
|
array = append(array, raw)
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(array) == 0 {
|
||||||
|
return nil, errors.New("input contains no JSON objects")
|
||||||
|
}
|
||||||
|
return array, nil
|
||||||
|
}
|
||||||
Reference in new issue
Block a user