cmsctl and docs
This commit is contained in:
1 parent
fea6eb51b1
commit
93cfa123e4
5 files changed
+316
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user