cmsctl and docs

This commit is contained in:
mats committed 2026-08-22 10:12:22 +02:00
1 parent fea6eb51b1
commit 93cfa123e4
5 files changed
+316

No files matched your search

+17
View File
@@ -57,6 +57,23 @@ backoffice proxy. Finder administration always uses `<base-url>/catalog/admin`.
The CLI intentionally has no separate finder-admin URL: the backoffice is the The CLI intentionally has no separate finder-admin URL: the backoffice is the
writeradmin boundary. writeradmin boundary.
## Todos and suggestions
List, filter, and complete improvement notes filed via the Suggest button or
API:
```bash
cmsctl todos list # all todos in triage sort (open first)
cmsctl todos list --open # open / uncompleted only (--done=false)
cmsctl todos list --done # completed only (--done=true)
cmsctl todos list --prio high --q "checkout" # filter by priority and search text
cmsctl todos done NODE_ID # mark completed (sets data.done = true)
cmsctl todos reopen NODE_ID # reopen (sets data.done = false)
cmsctl todos get NODE_ID # get todo node
cmsctl todos create --note "Fix cart layout" --prio high --location "Commerce · Cart"
cmsctl todos delete NODE_ID --yes
```
## CMS ## CMS
```bash ```bash
+94
View File
@@ -293,3 +293,97 @@ func TestFinderAdminAlwaysUsesBackofficeBaseURL(t *testing.T) {
t.Fatalf("requests = %v %v", methods, paths) t.Fatalf("requests = %v %v", methods, paths)
} }
} }
func TestTodosListFiltersAndDoneCommands(t *testing.T) {
var requests []struct {
Method string
Path string
Query string
Body string
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
requests = append(requests, struct {
Method string
Path string
Query string
Body string
}{
Method: r.Method,
Path: r.URL.Path,
Query: r.URL.RawQuery,
Body: string(body),
})
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodPatch {
_, _ = io.WriteString(w, `{"id":"todo-1","dataType":"todo","data":{"done":true}}`)
return
}
if r.Method == http.MethodPost {
_, _ = io.WriteString(w, `{"id":"todo-created","dataType":"todo"}`)
return
}
_, _ = io.WriteString(w, `{"items":[],"total":0}`)
}))
defer server.Close()
client := newClient(Config{BaseURL: server.URL}, "token", "", time.Second)
var output bytes.Buffer
// 1. List with --open
if err := runTodos(context.Background(), client, []string{"list", "--open", "--prio", "high", "--q", "cart"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("todos list --open: %v", err)
}
if len(requests) != 1 || requests[0].Path != "/api/todos" {
t.Fatalf("unexpected request: %+v", requests)
}
if !strings.Contains(requests[0].Query, "done=false") || !strings.Contains(requests[0].Query, "prio=high") || !strings.Contains(requests[0].Query, "q=cart") {
t.Fatalf("unexpected query string: %q", requests[0].Query)
}
// 2. List with --done=true
requests = nil
if err := runTodos(context.Background(), client, []string{"list", "--done=true"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("todos list --done=true: %v", err)
}
if len(requests) != 1 || !strings.Contains(requests[0].Query, "done=true") {
t.Fatalf("expected done=true in query: %q", requests[0].Query)
}
// 3. Mark done
requests = nil
if err := runTodos(context.Background(), client, []string{"done", "todo-123"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("todos done: %v", err)
}
if len(requests) != 1 || requests[0].Method != http.MethodPatch || requests[0].Path != "/api/nodes/todo-123" {
t.Fatalf("unexpected mark done request: %+v", requests)
}
if !strings.Contains(requests[0].Body, `"done":true`) {
t.Fatalf("expected done:true in patch body: %q", requests[0].Body)
}
// 4. Reopen
requests = nil
if err := runTodos(context.Background(), client, []string{"reopen", "todo-123"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("todos reopen: %v", err)
}
if len(requests) != 1 || requests[0].Method != http.MethodPatch || requests[0].Path != "/api/nodes/todo-123" {
t.Fatalf("unexpected reopen request: %+v", requests)
}
if !strings.Contains(requests[0].Body, `"done":false`) {
t.Fatalf("expected done:false in reopen body: %q", requests[0].Body)
}
// 5. Create
requests = nil
if err := runTodos(context.Background(), client, []string{"create", "--note", "Fix button", "--prio", "high", "--location", "Admin"}, strings.NewReader(""), &output); err != nil {
t.Fatalf("todos create: %v", err)
}
if len(requests) != 1 || requests[0].Method != http.MethodPost || requests[0].Path != "/api/nodes" {
t.Fatalf("unexpected create request: %+v", requests)
}
if !strings.Contains(requests[0].Body, `"dataType":"todo"`) || !strings.Contains(requests[0].Body, `"note":"Fix button"`) {
t.Fatalf("unexpected create body: %q", requests[0].Body)
}
}
+2
View File
@@ -19,6 +19,8 @@ func runCMS(ctx context.Context, client *Client, args []string, stdin io.Reader,
return nil return nil
} }
switch args[0] { switch args[0] {
case "todos", "todo":
return runTodos(ctx, client, args[1:], stdin, stdout)
case "nodes": case "nodes":
return runCMSNodes(ctx, client, args[1:], stdin, stdout) return runCMSNodes(ctx, client, args[1:], stdin, stdout)
case "search": case "search":
+10
View File
@@ -25,6 +25,13 @@ Usage:
cmsctl auth token [--raw] Print the persisted token cmsctl auth token [--raw] Print the persisted token
cmsctl auth logout Remove the persisted token cmsctl auth logout Remove the persisted token
cmsctl todos [list] [flags] List todos (?done, prio, q, limit)
cmsctl todos done <id> Mark a todo as completed
cmsctl todos reopen <id> Mark a todo as open / not done
cmsctl todos get <id> Get one todo node
cmsctl todos create --note '...' [flags] Create a new todo
cmsctl todos delete <id> --yes Delete a todo node
cmsctl cms nodes list [flags] List CMS nodes cmsctl cms nodes list [flags] List CMS nodes
cmsctl cms nodes get <id> Get one node cmsctl cms nodes get <id> Get one node
cmsctl cms nodes create [flags] Create a node cmsctl cms nodes create [flags] Create a node
@@ -137,6 +144,9 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) erro
switch rest[0] { switch rest[0] {
case "auth": case "auth":
return runAuth(ctx, cfgPath, &cfg, rest[1:], stdout, stderr, global.timeout) return runAuth(ctx, cfgPath, &cfg, rest[1:], stdout, stderr, global.timeout)
case "todos", "todo":
client := newClient(cfg, global.token, global.apiKey, global.timeout)
return runTodos(ctx, client, rest[1:], stdin, stdout)
case "cms": case "cms":
client := newClient(cfg, global.token, global.apiKey, global.timeout) client := newClient(cfg, global.token, global.apiKey, global.timeout)
return runCMS(ctx, client, rest[1:], stdin, stdout) return runCMS(ctx, client, rest[1:], stdin, stdout)
+193
View File
@@ -0,0 +1,193 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/url"
"strconv"
"strings"
)
func runTodos(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error {
if len(args) == 0 {
return runTodosList(ctx, client, nil, stdout)
}
if args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
_, _ = io.WriteString(stdout, "Usage: cmsctl todos [list] [--done=true|false|open|done|all] [--open] [--prio low|medium|high] [--q query] [--limit N] [--offset N]\n cmsctl todos done <id>\n cmsctl todos reopen <id>\n cmsctl todos get <id>\n cmsctl todos create --note '...' [--prio low|medium|high] [--location '...'] [--url '...']\n cmsctl todos delete <id> --yes\n")
return nil
}
// If the first argument is a flag (e.g. `cmsctl todos --open`), treat as `list`
if strings.HasPrefix(args[0], "-") {
return runTodosList(ctx, client, args, stdout)
}
switch args[0] {
case "list":
return runTodosList(ctx, client, args[1:], stdout)
case "done", "mark-done":
return runTodosSetDone(ctx, client, args[1:], true, stdout)
case "reopen", "undone", "open":
return runTodosSetDone(ctx, client, args[1:], false, stdout)
case "get":
id, err := requireID(args[1:], "cmsctl todos get requires a todo id")
if err != nil {
return err
}
body, err := client.get(ctx, client.cmsURL("/nodes/"+escapePathSegment(id)))
if err != nil {
return err
}
return writeRaw(stdout, body)
case "create":
return runTodosCreate(ctx, client, args[1:], stdin, stdout)
case "delete":
return cmsDeleteNode(ctx, client, args[1:], stdout)
default:
return fmt.Errorf("unknown todos command %q (use --help)", args[0])
}
}
type doneFlag struct {
set bool
value string
}
func (d *doneFlag) String() string {
return d.value
}
func (d *doneFlag) Set(s string) error {
d.set = true
d.value = strings.TrimSpace(strings.ToLower(s))
return nil
}
func (d *doneFlag) IsBoolFlag() bool {
return true
}
func runTodosList(ctx context.Context, client *Client, args []string, stdout io.Writer) error {
fs := flag.NewFlagSet("todos list", flag.ContinueOnError)
var done doneFlag
fs.Var(&done, "done", "filter by completion status: true|false, open|done, or all")
openOnly := fs.Bool("open", false, "filter to open / uncompleted todos (shorthand for --done=false)")
prio := fs.String("prio", "", "priority filter: low, medium, or high")
q := fs.String("q", "", "search substring over note/location/url")
limit := fs.Int("limit", 100, "page size; use 0 for all")
offset := fs.Int("offset", 0, "page offset")
fs.SetOutput(io.Discard)
if err := fs.Parse(args); err != nil {
return err
}
query := url.Values{
"limit": {strconv.Itoa(*limit)},
"offset": {strconv.Itoa(*offset)},
}
if *prio != "" {
query.Set("prio", strings.ToLower(*prio))
}
if *q != "" {
query.Set("q", *q)
}
if *openOnly {
query.Set("done", "false")
} else if done.set {
switch done.value {
case "true", "t", "1", "done", "yes":
query.Set("done", "true")
case "false", "f", "0", "open", "no":
query.Set("done", "false")
case "all", "":
// omit parameter to fetch all
default:
return fmt.Errorf("invalid --done value %q: use true, false, open, done, or all", done.value)
}
}
body, err := client.get(ctx, client.cmsURL("/todos")+"?"+query.Encode())
if err != nil {
return err
}
return writeRaw(stdout, body)
}
func runTodosSetDone(ctx context.Context, client *Client, args []string, done bool, stdout io.Writer) error {
id, err := requireID(args, "cmsctl todos done requires a todo id")
if err != nil {
return err
}
patch := map[string]any{
"data": map[string]any{
"done": done,
},
}
body, err := json.Marshal(patch)
if err != nil {
return err
}
response, err := client.patchBody(ctx, client.cmsURL("/nodes/"+escapePathSegment(id)), body, "application/json")
if err != nil {
return err
}
return writeRaw(stdout, response)
}
func runTodosCreate(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error {
fs := flag.NewFlagSet("todos create", flag.ContinueOnError)
note := fs.String("note", "", "improvement note / description")
prio := fs.String("prio", "medium", "priority: low, medium, or high")
done := fs.Bool("done", false, "initial completion status")
location := fs.String("location", "", "admin view title where suggestion was filed")
rawURL := fs.String("url", "", "page URL where suggestion was filed")
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 todo input: %w", err)
}
if len(body) == 0 {
if strings.TrimSpace(*note) == "" {
return errors.New("todos create requires --note")
}
data := map[string]any{
"note": strings.TrimSpace(*note),
"prio": *prio,
"done": *done,
}
if *location != "" {
data["location"] = *location
}
if *rawURL != "" {
data["url"] = *rawURL
}
node := map[string]any{
"dataType": "todo",
"data": data,
"tags": splitCSV(*tags),
}
body, err = json.Marshal(node)
if err != nil {
return err
}
}
if !json.Valid(body) {
return errors.New("create todo 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)
}