Files
plainleaf/cli/get.go
T

258 lines
8.0 KiB
Go

package cli
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"github.com/spf13/cobra"
)
// rawRequest performs an HTTP request against the space and returns the raw body and status.
// Unlike apiGet/apiPost, it does not interpret the body or fail on non-2xx — the caller decides.
func (c *SpaceConnection) rawRequest(method, path string, body io.Reader) ([]byte, int, error) {
headers, err := c.buildHeaders()
if err != nil {
return nil, 0, err
}
req, err := http.NewRequest(method, c.baseURL+path, body)
if err != nil {
return nil, 0, err
}
req.Header = headers
resp, err := c.client().Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
if resp.StatusCode == 401 || (resp.StatusCode >= 300 && resp.StatusCode < 400 && resp.StatusCode != 304) {
// Don't surface auth-required as a "raw" response; emit a clear error.
return nil, resp.StatusCode, fmt.Errorf("authentication required; use --token, or configure a space with 'space add'")
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return respBody, resp.StatusCode, nil
}
func GetCommand() *cobra.Command {
var (
selector []string
where []string
sortBy []string
limit int
offset int
selects string
verbose bool
)
cmd := &cobra.Command{
Use: "get [tag] [ref]",
Short: "List indexed tags, list objects of a tag, or fetch one object (kubectl-style)",
Long: `Retrieve indexed objects.
sb get # list all used tag names
sb get <tag> # list all objects with this tag (paged, filtered)
sb get <tag> <ref> # fetch one object by its ref
OBJECTS AND TAGS
Each object has a "ref" field that uniquely identifies it within its tag.
For pages, the ref is the page name; for many others, it's typically "PageName@PositionIndex".
Run 'sb describe <tag>' to see the field schema for a tag (when defined).
FILTERING (--where, -l/--selector)
-l, --selector field=value[,field=value]
Comma-separated equality selectors. Quickest path for AND-of-equalities.
--where <expr> (repeatable)
Full operator support. Syntax: 'field=value' (equality) or 'field:OP=value'.
Multiple --where flags are AND-ed together.
Operators:
eq equal to --where done=false
ne not equal --where status:ne=archived
gt numeric/string greater than --where priority:gt=2
gte greater than or equal --where due:gte=2026-01-01
lt less than --where lineCount:lt=100
lte less than or equal --where due:lte=2026-06-01
in value in comma-separated list --where status:in=open,pending
contains string contains substring --where name:contains=meeting
startsWith string starts with prefix --where name:startsWith=2026-
Field paths use dotted notation for nested fields:
--where meta.author=alice
VALUE TYPING
Values are auto-typed:
42, -3.14 -> number
true, false -> boolean
null -> nil (matches missing or explicit-null fields)
anything else -> string
Force a type with a prefix:
--where zipCode=str:01234 # treat as string, not number
--where count:gt=num:10 # explicit number
--where active=bool:true # explicit boolean
SORTING, PAGING, PROJECTION
--sort-by field[:desc] (repeatable) — multi-key sort
--limit N default 100, max 1000
--offset N pagination offset
--select f1,f2,... project only these fields per result
OUTPUT (global -o, --output | --text | --json)
auto (default) Text on a TTY (table for object lists), JSON otherwise — for humans + pipes
text string-as-string; arrays of objects render as a kubectl-style table; fallback to pretty JSON
table force table rendering (up to 8 columns, 40-char cells)
json pretty-printed JSON
jsonl one JSON value per line — friendly to xargs / line-oriented tools
yaml YAML
--text and --json are shortcuts for -o text / -o json.
VERBOSE (-v)
Adds the synthesized Lua query as an X-Equivalent-Lua response header
and includes it in stderr. Useful for understanding what the server ran.
EXIT CODES
0 success
1 transport / connection error
2 API error (non-2xx other than 404)
3 not found (404) — when fetching a single object by ref
ALSO
sb describe <tag> show the field schema for a tag
sb query '<lua>' full Lua collection query (for things REST can't express)`,
Example: ` # List all known tag names
sb get
# List all tasks
sb get task
# Unfinished tasks, highest priority first, top 20
sb get task -l done=false --sort-by priority:desc --limit 20
`,
Args: cobra.RangeArgs(0, 2),
RunE: func(cmd *cobra.Command, args []string) error {
conn, err := connFromFlags(cmd)
if err != nil {
return err
}
// No args: list known tag names
if len(args) == 0 {
body, status, err := conn.rawRequest(http.MethodGet, "/.runtime/objects", nil)
if err != nil {
return err
}
if status != 200 {
return apiError(status, body)
}
return FormatOutputBytes(os.Stdout, body, OutputModeFromCmd(cmd))
}
tag := args[0]
// Single-object get
if len(args) == 2 {
p := "/.runtime/objects/" + url.PathEscape(tag) + "/" + url.PathEscape(args[1])
body, status, err := conn.rawRequest(http.MethodGet, p, nil)
if err != nil {
return err
}
if status == 404 {
fmt.Fprintln(os.Stderr, "Not found")
os.Exit(3)
}
if status != 200 {
return apiError(status, body)
}
return FormatOutputBytes(os.Stdout, body, OutputModeFromCmd(cmd))
}
// List
v := url.Values{}
for _, s := range selector {
for _, kv := range strings.Split(s, ",") {
eq := strings.SplitN(kv, "=", 2)
if len(eq) != 2 {
return fmt.Errorf("invalid -l value: %q (want field=value)", kv)
}
v.Add("where["+eq[0]+"]", eq[1])
}
}
for _, w := range where {
eq := strings.SplitN(w, "=", 2)
if len(eq) != 2 {
return fmt.Errorf("invalid --where: %q (want field=val or field:op=val)", w)
}
key, val := eq[0], eq[1]
if i := strings.IndexByte(key, ':'); i > 0 {
field, op := key[:i], key[i+1:]
v.Add("where["+field+"]["+op+"]", val)
} else {
v.Add("where["+key+"]", val)
}
}
for _, s := range sortBy {
v.Add("order", s)
}
if limit > 0 {
v.Set("limit", fmt.Sprintf("%d", limit))
}
if offset > 0 {
v.Set("offset", fmt.Sprintf("%d", offset))
}
if selects != "" {
v.Set("select", selects)
}
if verbose {
v.Set("debug", "1")
}
p := "/.runtime/objects/" + url.PathEscape(tag)
if encoded := v.Encode(); encoded != "" {
p += "?" + encoded
}
body, status, err := conn.rawRequest(http.MethodGet, p, nil)
if err != nil {
return err
}
if status != 200 {
return apiError(status, body)
}
return FormatOutputBytes(os.Stdout, body, OutputModeFromCmd(cmd))
},
}
cmd.Flags().StringSliceVarP(&selector, "selector", "l", nil, "Equality selectors: field=val,field2=val2")
cmd.Flags().StringSliceVar(&where, "where", nil, "Filter clause: field=val or field:op=val (repeatable)")
cmd.Flags().StringSliceVar(&sortBy, "sort-by", nil, "Sort key: field or field:desc (repeatable)")
cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of results")
cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset")
cmd.Flags().StringVar(&selects, "select", "", "Comma-separated projection fields")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Include equivalent Lua in debug output")
return cmd
}
func apiError(status int, body []byte) error {
var apiErr struct {
Error string `json:"error"`
Code string `json:"code"`
}
_ = json.Unmarshal(body, &apiErr)
msg := apiErr.Error
if msg == "" {
msg = string(body)
}
return fmt.Errorf("API error (HTTP %d, code=%s): %s", status, apiErr.Code, msg)
}