diff --git a/cmd/cmsctl/README.md b/cmd/cmsctl/README.md new file mode 100644 index 0000000..ae2880a --- /dev/null +++ b/cmd/cmsctl/README.md @@ -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 `/find`, which is the +backoffice proxy. Finder administration always uses `/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 (`/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. diff --git a/cmd/cmsctl/client.go b/cmd/cmsctl/client.go new file mode 100644 index 0000000..b5edfd3 --- /dev/null +++ b/cmd/cmsctl/client.go @@ -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 +} diff --git a/cmd/cmsctl/cmsctl_test.go b/cmd/cmsctl/cmsctl_test.go new file mode 100644 index 0000000..7a30d4b --- /dev/null +++ b/cmd/cmsctl/cmsctl_test.go @@ -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) + } +} diff --git a/cmd/cmsctl/commands.go b/cmd/cmsctl/commands.go new file mode 100644 index 0000000..db48a46 --- /dev/null +++ b/cmd/cmsctl/commands.go @@ -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 diff --git a/cmd/cmsctl/config.go b/cmd/cmsctl/config.go new file mode 100644 index 0000000..cd11c02 --- /dev/null +++ b/cmd/cmsctl/config.go @@ -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" +} diff --git a/cmd/cmsctl/extra_commands.go b/cmd/cmsctl/extra_commands.go new file mode 100644 index 0000000..e226367 --- /dev/null +++ b/cmd/cmsctl/extra_commands.go @@ -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 \n cmsctl cms edges create [--from ID --to ID --type child --handle NAME --sort N]\n cmsctl cms edges delete --yes\n cmsctl cms edges reorder --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]) + } +} diff --git a/cmd/cmsctl/main.go b/cmd/cmsctl/main.go new file mode 100644 index 0000000..ba8d45a --- /dev/null +++ b/cmd/cmsctl/main.go @@ -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 Get one node + cmsctl cms nodes create [flags] Create a node + cmsctl cms nodes update [flags] Replace a node + cmsctl cms nodes patch [flags] Merge fields into a node + cmsctl cms nodes delete --yes Delete a node + cmsctl cms nodes retype --type TYPE Change node type + cmsctl cms edges {get|create|delete|reorder} Manage graph edges + cmsctl cms schemas {list|get|put|delete} Manage JSON Schemas + cmsctl cms templates {list|get|put|delete} Manage render templates + cmsctl cms blueprints {list|create} Inspect/apply blueprints + cmsctl cms tree Read a composition tree + cmsctl cms nav [root-id] Read navigation hierarchy + cmsctl cms graph [flags] Read a graph region + cmsctl cms publish ... Publish roots + cmsctl cms regenerate [--type TYPE] Regenerate static output + cmsctl cms script --script '...' Run a CMS script + cmsctl cms search --q Search CMS nodes + cmsctl cms types List registered node types + cmsctl cms import [file|-] Import nodes and edges + + cmsctl finder search [flags] Search catalog (JSONL output) + cmsctl finder get Get one catalog item + cmsctl finder config [flags] Browse items and facets + cmsctl finder facets [flags] List query facets + cmsctl finder facet-list List configured facets + cmsctl finder values List values for a facet + cmsctl finder stream-items [file|-] Stream items by numeric IDs + cmsctl finder suggest --q Catalog suggestions + cmsctl finder popular Popular catalog items + cmsctl finder related Related catalog items + cmsctl finder compatible Compatible catalog items + cmsctl finder similar Visually similar items + cmsctl finder relation-groups|facet-groups Read finder configuration + cmsctl finder save-trigger Ask reader to persist state + cmsctl finder import [file|-] Import inventory items (JSON/JSONL) + +All commands accept --config, --base-url, --finder-url, --api-key, --token, +and --timeout before the command. A missing --finder-url uses /find +for reads. Finder administration always uses /catalog/admin. + +Pipe examples: + cat nodes.json | cmsctl cms import - + jq '{data: .data, tags: .tags}' node.json | cmsctl cms nodes update ID --input - + cat inventory.jsonl | cmsctl finder import - + cmsctl finder search --q drill --limit 20 | jq -c . +` + +type globalOptions struct { + configPath string + baseURL string + finderURL string + apiKey string + token string + timeout time.Duration +} + +func main() { + if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, "cmsctl:", err) + os.Exit(1) + } +} + +func run(args []string, stdout, stderr io.Writer) error { + return runWithStdin(args, os.Stdin, stdout, stderr) +} + +func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) error { + if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { + _, _ = io.WriteString(stdout, usageText) + return nil + } + global, rest, err := parseGlobal(args) + if err != nil { + return err + } + if len(rest) == 0 { + _, _ = io.WriteString(stdout, usageText) + return nil + } + + cfgPath, err := configPath(global.configPath) + if err != nil { + return err + } + cfg, err := loadConfig(cfgPath) + if err != nil { + return err + } + if global.baseURL != "" { + cfg.BaseURL = global.baseURL + } + if global.finderURL != "" { + cfg.FinderURL = global.finderURL + } + if global.token == "" { + global.token = os.Getenv("CMSCTL_TOKEN") + } + if global.apiKey == "" { + global.apiKey = os.Getenv("CMSCTL_API_KEY") + } + if global.token == "" && global.apiKey == "" { + global.token = cfg.AccessToken + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + switch rest[0] { + case "auth": + return runAuth(ctx, cfgPath, &cfg, rest[1:], stdout, stderr, global.timeout) + case "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 +}