From 5b5f35cf6d8b4efca3f03a3d42225969025772a5 Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 11 Aug 2026 01:00:47 +0100 Subject: [PATCH 01/10] feat: Reach every agent endpoint from the CLI The CLI covered deployments, images and containers, which is a fraction of what the agent can do, and each new endpoint waited on someone writing a wrapper for it. Backups, certificates, databases, domains, the scheduler, security, object stores, users and keys were all out of reach except through the raw bridge. Every endpoint is now a command, from a table generated out of the agent's own routes, so the question of whether the CLI has caught up is answered by regenerating it rather than by reading both codebases. The commands that print something worth reading are still shaped by hand; the rest take their arguments from the path, a body from repeated fields or raw JSON, and query parameters as they come. Anything driving the CLI without a human can now ask what exists: one command prints every family, operation, method and path, as JSON if asked. --- CHANGELOG.md | 13 ++ README.md | 41 +++- VERSION | 2 +- docs/reference/commands.md | 51 ++++- internal/command/endpoints.go | 324 +++++++++++++++++++++++++++++ internal/command/endpoints_gen.go | 300 ++++++++++++++++++++++++++ internal/command/endpoints_test.go | 212 +++++++++++++++++++ internal/command/root.go | 23 +- tools/gen_endpoints.py | 146 +++++++++++++ 9 files changed, 1108 insertions(+), 4 deletions(-) create mode 100644 internal/command/endpoints.go create mode 100644 internal/command/endpoints_gen.go create mode 100644 internal/command/endpoints_test.go create mode 100644 tools/gen_endpoints.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bfcc0b6..214a1fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to the FlatRun CLI are documented in this file. +## [0.3.0] - 2026-08-11 + +### Added + +- Every endpoint the agent exposes is now a command, as `flatrun FAMILY OPERATION [ARGS]`, covering 294 endpoints across 42 resource families including backups, certificates, databases, domains, security, scheduler, object stores, users and API keys. The command table is generated from the agent's own routes rather than written by hand, so the CLI reaches a new endpoint as soon as it is regenerated instead of trailing behind by a release. +- `flatrun commands` lists every command, and `flatrun commands --json` prints the same list with each command's method, path and arguments, so a script or an agent can discover the whole surface without reading the docs. +- `flatrun` with no arguments and `flatrun FAMILY` with no operation list what is available at that level. +- Request bodies can be built with repeatable `-f name=value` fields, or passed whole with `--data JSON` or `--data @file.json`. A field value that reads as JSON is sent as JSON, so `-f enabled=true` sends a boolean rather than a string. Query parameters go in with repeatable `-q name=value`. + +### Fixed + +- Single-dash flags with values (`-url`, `-token`, `-data`) were parsed as though they took no value, so the following argument was swallowed. Both spellings now work, as the flag package intends. + ## [0.2.0] - 2026-06-15 ### Added diff --git a/README.md b/README.md index 729f4e9..80aa19c 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,46 @@ flatrun container exec abc123 -- sh -c 'printenv | sort' `deployment action` runs a quick action defined on the deployment; `deployment actions` lists them. `deployment exec` runs an ad-hoc command instead: the command follows `--`, and the service is chosen positionally or with `--service` (a single-service deployment is resolved automatically, a multi-service one must be named). Both run in the service container, honor the deployment's protected-mode rules, and surface the command's output (including on a non-zero exit). -Call any backend endpoint while a polished command is still pending: +### Every other resource + +The commands above are shaped by hand because they print tables worth reading. Every other +endpoint the agent exposes is reachable as `flatrun FAMILY OPERATION [ARGS]`, from a table +generated out of the agent's own routes, so a new endpoint there does not wait on a wrapper here. + +```bash +flatrun # lists the resource families +flatrun backups # lists what can be done with backups +flatrun backups list +flatrun certificates renew shop.example.com +flatrun deployment logs my-api -q service=web -q tail=200 +``` + +Send a body as fields or as JSON: + +```bash +flatrun domains create -f domain=shop.example.com -f deployment=shop +flatrun settings update --data '{"backups":{"enabled":true}}' +flatrun settings update --data @settings.json +``` + +A field value that reads as JSON is sent as JSON, so `-f enabled=true` sends a boolean and +`-f retention=7` sends a number. Query parameters go in with `-q name=value`. + +### Driving the CLI from a script or an agent + +`flatrun commands --json` prints every command with its method, path and arguments, which is +enough for a program to discover the whole surface without reading these docs: + +```bash +flatrun commands --json | jq '.[] | select(.family == "backups")' +flatrun commands backups +``` + +Add `--json` to any command for the raw response. + +### The raw bridge + +Still available for anything the table does not cover, such as a streaming endpoint: ```bash flatrun api get /settings diff --git a/VERSION b/VERSION index 0ea3a94..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.3.0 diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 6b97ee9..697a3a5 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -130,9 +130,58 @@ flatrun container restart CONTAINER_ID flatrun container delete CONTAINER_ID ``` +## Every other resource + +The families above are shaped by hand. Every other endpoint the agent exposes is reachable as +`flatrun FAMILY OPERATION [ARGS]`, from a table generated out of the agent's own routes. + +```bash +flatrun # the resource families +flatrun backups # what can be done with backups +flatrun backups list +flatrun backups restore BACKUP_ID +flatrun certificates renew shop.example.com +flatrun scheduler tasks +``` + +The operation names follow the endpoint. A collection reads as `list`, one item as `get`, and a +sub-resource keeps its own noun (`log-sources`, `actions`, `jobs`). Where a read and a write share +a path, the read keeps the plain name and the write says what it does (`log-sources` and +`log-sources-update`). Where the same verb applies to one item and to all of them, the targeted +one keeps the plain name (`certificates renew DOMAIN`, `certificates renew-all`). + +### Sending a body + +```bash +flatrun domains create -f domain=shop.example.com -f deployment=shop +flatrun settings update --data '{"backups":{"enabled":true}}' +flatrun settings update --data @settings.json +``` + +`-f name=value` is repeatable. A value that reads as JSON is sent as JSON, so `-f enabled=true` +sends a boolean, `-f retention=7` sends a number, and `-f ports=[8080]` sends an array. Use +`--data` for anything nested enough that fields get awkward; the two cannot be combined. + +### Query parameters + +```bash +flatrun deployment logs my-api -q service=web -q tail=200 +``` + +## Discovering the surface + +```bash +flatrun commands # every command +flatrun commands backups # one family +flatrun commands --json # the same list as JSON +``` + +The JSON form gives each command's family, operation, method, path, arguments and the exact +invocation, which is what a script or an agent needs to use the CLI without reading this page. + ## Raw API -Use the raw API bridge while a polished command is still pending: +Use the raw API bridge for anything the table does not cover, such as a streaming endpoint: ```bash flatrun api get /settings diff --git a/internal/command/endpoints.go b/internal/command/endpoints.go new file mode 100644 index 0000000..a871ab0 --- /dev/null +++ b/internal/command/endpoints.go @@ -0,0 +1,324 @@ +package command + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/url" + "os" + "sort" + "strconv" + "strings" + + "github.com/flatrun/cli/internal/flatrun" +) + +// endpoint is one agent API endpoint, reachable as `flatrun FAMILY OP ARGS...`. The table is +// generated from the agent's routes rather than written by hand, so an endpoint the agent adds +// is one regeneration away from being a command instead of a hand-written wrapper that drifts. +type endpoint struct { + family string + op string + method string + path string + args []string +} + +// command is what an operator types, and what `flatrun commands` prints. +func (e endpoint) command() string { + parts := []string{"flatrun", e.family, e.op} + for _, arg := range e.args { + parts = append(parts, strings.ToUpper(arg)) + } + return strings.Join(parts, " ") +} + +func (e endpoint) writes() bool { return e.method != "GET" } + +// resolvePath substitutes the positional arguments into the path parameters. +func (e endpoint) resolvePath(args []string) (string, error) { + if len(args) != len(e.args) { + return "", fmt.Errorf("expected %d argument(s): %s", len(e.args), e.command()) + } + path := e.path + for i, name := range e.args { + if args[i] == "" { + return "", fmt.Errorf("%s cannot be empty: %s", strings.ToUpper(name), e.command()) + } + path = strings.Replace(path, ":"+name, url.PathEscape(args[i]), 1) + } + return path, nil +} + +func endpointsByFamily() map[string][]endpoint { + families := map[string][]endpoint{} + for _, e := range generatedEndpoints { + families[e.family] = append(families[e.family], e) + } + for _, list := range families { + sort.Slice(list, func(i, j int) bool { return list[i].op < list[j].op }) + } + return families +} + +func findEndpoint(family, op string) (endpoint, bool) { + for _, e := range generatedEndpoints { + if e.family == family && e.op == op { + return e, true + } + } + return endpoint{}, false +} + +func knownFamily(family string) bool { + for _, e := range generatedEndpoints { + if e.family == family { + return true + } + } + return false +} + +// fieldValues collects repeated -f name=value pairs into a request body. A value that parses as +// JSON is kept as JSON, so -f enabled=true sends a boolean rather than the word. +type fieldValues map[string]any + +func (f fieldValues) String() string { return "" } + +func (f fieldValues) Set(raw string) error { + name, value, found := strings.Cut(raw, "=") + if !found || name == "" { + return fmt.Errorf("expected name=value, got %q", raw) + } + f[name] = parseFieldValue(value) + return nil +} + +func parseFieldValue(value string) any { + if value == "" { + return "" + } + switch value { + case "true": + return true + case "false": + return false + case "null": + return nil + } + if n, err := strconv.ParseFloat(value, 64); err == nil { + return n + } + if strings.HasPrefix(value, "{") || strings.HasPrefix(value, "[") { + var nested any + if err := json.Unmarshal([]byte(value), &nested); err == nil { + return nested + } + } + return value +} + +type queryValues url.Values + +func (q queryValues) String() string { return "" } + +func (q queryValues) Set(raw string) error { + name, value, found := strings.Cut(raw, "=") + if !found || name == "" { + return fmt.Errorf("expected name=value, got %q", raw) + } + url.Values(q).Add(name, value) + return nil +} + +// runEndpoint dispatches `flatrun FAMILY OP ...` against the generated table. +func runEndpoint(family string, args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + printFamily(stdout, family) + return 0 + } + switch args[0] { + case "help", "-h", "--help": + printFamily(stdout, family) + return 0 + } + + e, ok := findEndpoint(family, args[0]) + if !ok { + _, _ = fmt.Fprintf(stderr, "Unknown %s command: %s\n\n", family, args[0]) + printFamily(stderr, family) + return 2 + } + + fields := fieldValues{} + query := queryValues{} + dataArg := "" + + cmd := clientCommand{ + name: family + " " + e.op, + usage: "Usage: " + e.command() + " [-f name=value] [--data JSON] [-q name=value]", + positionals: len(e.args), + valueFlags: []string{"data", "f", "q"}, + flags: func(fs *flag.FlagSet) { + fs.StringVar(&dataArg, "data", "", "JSON request body, or @file to read one") + fs.Var(fields, "f", "Request body field as name=value, repeatable") + fs.Var(query, "q", "Query parameter as name=value, repeatable") + }, + run: func(ctx context.Context, client *flatrun.Client, positional []string) ([]byte, error) { + path, err := e.resolvePath(positional) + if err != nil { + return nil, err + } + if len(query) > 0 { + path += "?" + url.Values(query).Encode() + } + payload, err := requestBody(dataArg, fields, e) + if err != nil { + return nil, err + } + return client.Do(ctx, e.method, path, payload) + }, + } + return runClientCommand(cmd, args[1:], stdout, stderr) +} + +func requestBody(dataArg string, fields fieldValues, e endpoint) (any, error) { + if dataArg != "" && len(fields) > 0 { + return nil, fmt.Errorf("use --data or -f, not both") + } + if dataArg != "" { + raw := []byte(dataArg) + if strings.HasPrefix(dataArg, "@") { + contents, err := os.ReadFile(strings.TrimPrefix(dataArg, "@")) + if err != nil { + return nil, err + } + raw = contents + } + var payload any + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, fmt.Errorf("invalid JSON body: %w", err) + } + return payload, nil + } + if len(fields) > 0 { + return map[string]any(fields), nil + } + if e.writes() { + // A write with no body is normal here: restarting a deployment or renewing a + // certificate carries nothing. + return nil, nil + } + return nil, nil +} + +func printFamily(w io.Writer, family string) { + list := endpointsByFamily()[family] + if len(list) == 0 { + _, _ = fmt.Fprintf(w, "No commands for %s\n", family) + return + } + _, _ = fmt.Fprintf(w, "flatrun %s\n\n", family) + for _, e := range list { + _, _ = fmt.Fprintf(w, " %-28s %s %s\n", e.op+" "+argNames(e), e.method, e.path) + } + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, "Send a body with -f name=value (repeatable) or --data JSON.") +} + +func argNames(e endpoint) string { + names := make([]string, 0, len(e.args)) + for _, arg := range e.args { + names = append(names, strings.ToUpper(arg)) + } + return strings.Join(names, " ") +} + +// runCommands prints every command the CLI can run. An agent driving this CLI needs to know +// what exists without a human reading the docs, which is what --json is for. +func runCommands(args []string, stdout, stderr io.Writer) int { + asJSON := false + family := "" + for _, arg := range args { + switch { + case arg == "--json": + asJSON = true + case strings.HasPrefix(arg, "-"): + _, _ = fmt.Fprintf(stderr, "Unknown flag: %s\n", arg) + return 2 + default: + family = arg + } + } + + list := make([]endpoint, 0, len(generatedEndpoints)) + for _, e := range generatedEndpoints { + if family == "" || e.family == family { + list = append(list, e) + } + } + if len(list) == 0 { + _, _ = fmt.Fprintf(stderr, "Unknown family: %s\n", family) + return 2 + } + sort.Slice(list, func(i, j int) bool { + if list[i].family != list[j].family { + return list[i].family < list[j].family + } + return list[i].op < list[j].op + }) + + if asJSON { + type wire struct { + Family string `json:"family"` + Op string `json:"op"` + Method string `json:"method"` + Path string `json:"path"` + Args []string `json:"args"` + Command string `json:"command"` + } + out := make([]wire, 0, len(list)) + for _, e := range list { + args := e.args + if args == nil { + args = []string{} + } + out = append(out, wire{e.family, e.op, e.method, e.path, args, e.command()}) + } + encoded, err := json.MarshalIndent(out, "", " ") + if err != nil { + _, _ = fmt.Fprintln(stderr, "Error:", err) + return 1 + } + _, _ = fmt.Fprintln(stdout, string(encoded)) + return 0 + } + + current := "" + for _, e := range list { + if e.family != current { + if current != "" { + _, _ = fmt.Fprintln(stdout) + } + current = e.family + _, _ = fmt.Fprintln(stdout, e.family) + } + _, _ = fmt.Fprintf(stdout, " %-30s %s %s\n", e.op+" "+argNames(e), e.method, e.path) + } + return 0 +} + +func families() []string { + seen := map[string]bool{} + names := []string{} + for _, e := range generatedEndpoints { + if !seen[e.family] { + seen[e.family] = true + names = append(names, e.family) + } + } + sort.Strings(names) + return names +} diff --git a/internal/command/endpoints_gen.go b/internal/command/endpoints_gen.go new file mode 100644 index 0000000..c837c62 --- /dev/null +++ b/internal/command/endpoints_gen.go @@ -0,0 +1,300 @@ +// Code generated by tools/gen_endpoints.py from the agent's route table. DO NOT EDIT. + +package command + +var generatedEndpoints = []endpoint{ + {family: "agent", op: "update", method: "GET", path: "/agent/update", args: nil}, + {family: "agent", op: "update-create", method: "POST", path: "/agent/update", args: nil}, + {family: "ai", op: "agents", method: "GET", path: "/ai/agents", args: nil}, + {family: "ai", op: "agents-delete", method: "DELETE", path: "/ai/agents/:name", args: []string{"name"}}, + {family: "ai", op: "agents-get", method: "GET", path: "/ai/agents/:name", args: []string{"name"}}, + {family: "ai", op: "agents-update", method: "PUT", path: "/ai/agents/:name", args: []string{"name"}}, + {family: "ai", op: "agents-run", method: "POST", path: "/ai/agents/:name/run", args: []string{"name"}}, + {family: "ai", op: "analyze", method: "POST", path: "/ai/analyze", args: nil}, + {family: "ai", op: "sessions", method: "GET", path: "/ai/sessions", args: nil}, + {family: "ai", op: "sessions-create", method: "POST", path: "/ai/sessions", args: nil}, + {family: "ai", op: "sessions-delete", method: "DELETE", path: "/ai/sessions/:id", args: []string{"id"}}, + {family: "ai", op: "sessions-get", method: "GET", path: "/ai/sessions/:id", args: []string{"id"}}, + {family: "ai", op: "sessions-approve", method: "POST", path: "/ai/sessions/:id/approve", args: []string{"id"}}, + {family: "ai", op: "sessions-messages", method: "POST", path: "/ai/sessions/:id/messages", args: []string{"id"}}, + {family: "ai", op: "status", method: "GET", path: "/ai/status", args: nil}, + {family: "apikeys", op: "delete", method: "DELETE", path: "/apikeys/:id", args: []string{"id"}}, + {family: "apikeys", op: "get", method: "GET", path: "/apikeys/:id", args: []string{"id"}}, + {family: "apikeys", op: "update", method: "PUT", path: "/apikeys/:id", args: []string{"id"}}, + {family: "apikeys", op: "revoke", method: "POST", path: "/apikeys/:id/revoke", args: []string{"id"}}, + {family: "audit", op: "cleanup", method: "DELETE", path: "/audit/cleanup", args: nil}, + {family: "audit", op: "events", method: "GET", path: "/audit/events", args: nil}, + {family: "audit", op: "events-get", method: "GET", path: "/audit/events/:id", args: []string{"id"}}, + {family: "audit", op: "export", method: "POST", path: "/audit/export", args: nil}, + {family: "audit", op: "stats", method: "GET", path: "/audit/stats", args: nil}, + {family: "auth", op: "login", method: "POST", path: "/auth/login", args: nil}, + {family: "auth", op: "status", method: "GET", path: "/auth/status", args: nil}, + {family: "auth", op: "validate", method: "GET", path: "/auth/validate", args: nil}, + {family: "backup-destinations", op: "list", method: "GET", path: "/backup-destinations", args: nil}, + {family: "backup-destinations", op: "test", method: "POST", path: "/backup-destinations/test", args: nil}, + {family: "backups", op: "list", method: "GET", path: "/backups", args: nil}, + {family: "backups", op: "create", method: "POST", path: "/backups", args: nil}, + {family: "backups", op: "delete", method: "DELETE", path: "/backups/:id", args: []string{"id"}}, + {family: "backups", op: "get", method: "GET", path: "/backups/:id", args: []string{"id"}}, + {family: "backups", op: "download", method: "GET", path: "/backups/:id/download", args: []string{"id"}}, + {family: "backups", op: "restore", method: "POST", path: "/backups/:id/restore", args: []string{"id"}}, + {family: "backups", op: "jobs", method: "GET", path: "/backups/jobs", args: nil}, + {family: "backups", op: "jobs-get", method: "GET", path: "/backups/jobs/:id", args: []string{"id"}}, + {family: "certificates", op: "list", method: "GET", path: "/certificates", args: nil}, + {family: "certificates", op: "create", method: "POST", path: "/certificates", args: nil}, + {family: "certificates", op: "delete", method: "DELETE", path: "/certificates/:domain", args: []string{"domain"}}, + {family: "certificates", op: "get", method: "GET", path: "/certificates/:domain", args: []string{"domain"}}, + {family: "certificates", op: "auto-renew", method: "PATCH", path: "/certificates/:domain/auto-renew", args: []string{"domain"}}, + {family: "certificates", op: "renew", method: "POST", path: "/certificates/:domain/renew", args: []string{"domain"}}, + {family: "certificates", op: "renew-all", method: "POST", path: "/certificates/renew", args: nil}, + {family: "cluster", op: "accept", method: "POST", path: "/cluster/accept", args: nil}, + {family: "cluster", op: "deployments", method: "GET", path: "/cluster/deployments", args: nil}, + {family: "cluster", op: "exchange", method: "POST", path: "/cluster/exchange", args: nil}, + {family: "cluster", op: "invite", method: "POST", path: "/cluster/invite", args: nil}, + {family: "cluster", op: "peers", method: "GET", path: "/cluster/peers", args: nil}, + {family: "cluster", op: "peers-delete", method: "DELETE", path: "/cluster/peers/:name", args: []string{"name"}}, + {family: "cluster", op: "stats", method: "GET", path: "/cluster/stats", args: nil}, + {family: "cluster", op: "status", method: "GET", path: "/cluster/status", args: nil}, + {family: "compose", op: "update", method: "POST", path: "/compose/update", args: nil}, + {family: "config", op: "list", method: "GET", path: "/config", args: nil}, + {family: "config", op: "*key", method: "GET", path: "/config/*key", args: nil}, + {family: "config", op: "*key-update", method: "PUT", path: "/config/*key", args: nil}, + {family: "containers", op: "list", method: "GET", path: "/containers", args: nil}, + {family: "containers", op: "delete", method: "DELETE", path: "/containers/:id", args: []string{"id"}}, + {family: "containers", op: "exec", method: "GET", path: "/containers/:id/exec", args: []string{"id"}}, + {family: "containers", op: "exec-create", method: "POST", path: "/containers/:id/exec", args: []string{"id"}}, + {family: "containers", op: "logs", method: "GET", path: "/containers/:id/logs", args: []string{"id"}}, + {family: "containers", op: "resources", method: "GET", path: "/containers/:id/resources", args: []string{"id"}}, + {family: "containers", op: "resources-update", method: "PUT", path: "/containers/:id/resources", args: []string{"id"}}, + {family: "containers", op: "restart", method: "POST", path: "/containers/:id/restart", args: []string{"id"}}, + {family: "containers", op: "start", method: "POST", path: "/containers/:id/start", args: []string{"id"}}, + {family: "containers", op: "stats-get", method: "GET", path: "/containers/:id/stats", args: []string{"id"}}, + {family: "containers", op: "stop", method: "POST", path: "/containers/:id/stop", args: []string{"id"}}, + {family: "containers", op: "stats", method: "GET", path: "/containers/stats", args: nil}, + {family: "credentials", op: "list", method: "GET", path: "/credentials", args: nil}, + {family: "credentials", op: "create", method: "POST", path: "/credentials", args: nil}, + {family: "credentials", op: "delete", method: "DELETE", path: "/credentials/:id", args: []string{"id"}}, + {family: "credentials", op: "get", method: "GET", path: "/credentials/:id", args: []string{"id"}}, + {family: "credentials", op: "update", method: "PUT", path: "/credentials/:id", args: []string{"id"}}, + {family: "credentials", op: "test", method: "POST", path: "/credentials/:id/test", args: []string{"id"}}, + {family: "dashboards", op: "list", method: "GET", path: "/dashboards", args: nil}, + {family: "dashboards", op: "create", method: "POST", path: "/dashboards", args: nil}, + {family: "dashboards", op: "delete", method: "DELETE", path: "/dashboards/:id", args: []string{"id"}}, + {family: "dashboards", op: "get", method: "GET", path: "/dashboards/:id", args: []string{"id"}}, + {family: "databases", op: "create", method: "POST", path: "/databases/create", args: nil}, + {family: "databases", op: "delete", method: "POST", path: "/databases/delete", args: nil}, + {family: "databases", op: "list", method: "POST", path: "/databases/list", args: nil}, + {family: "databases", op: "privileges-grant", method: "POST", path: "/databases/privileges/grant", args: nil}, + {family: "databases", op: "query", method: "POST", path: "/databases/query", args: nil}, + {family: "databases", op: "tables", method: "POST", path: "/databases/tables", args: nil}, + {family: "databases", op: "tables-data", method: "POST", path: "/databases/tables/data", args: nil}, + {family: "databases", op: "tables-schema", method: "POST", path: "/databases/tables/schema", args: nil}, + {family: "databases", op: "test", method: "POST", path: "/databases/test", args: nil}, + {family: "databases", op: "users", method: "POST", path: "/databases/users", args: nil}, + {family: "databases", op: "users-by-database", method: "POST", path: "/databases/users/by-database", args: nil}, + {family: "databases", op: "users-create", method: "POST", path: "/databases/users/create", args: nil}, + {family: "databases", op: "users-delete", method: "POST", path: "/databases/users/delete", args: nil}, + {family: "deployments", op: "list", method: "GET", path: "/deployments", args: nil}, + {family: "deployments", op: "create", method: "POST", path: "/deployments", args: nil}, + {family: "deployments", op: "delete", method: "DELETE", path: "/deployments/:name", args: []string{"name"}}, + {family: "deployments", op: "get", method: "GET", path: "/deployments/:name", args: []string{"name"}}, + {family: "deployments", op: "update", method: "PUT", path: "/deployments/:name", args: []string{"name"}}, + {family: "deployments", op: "actions", method: "POST", path: "/deployments/:name/actions/:actionId", args: []string{"name", "actionId"}}, + {family: "deployments", op: "ai-analyze", method: "POST", path: "/deployments/:name/ai/analyze", args: []string{"name"}}, + {family: "deployments", op: "backup-config", method: "GET", path: "/deployments/:name/backup-config", args: []string{"name"}}, + {family: "deployments", op: "backup-config-update", method: "PUT", path: "/deployments/:name/backup-config", args: []string{"name"}}, + {family: "deployments", op: "backups", method: "GET", path: "/deployments/:name/backups", args: []string{"name"}}, + {family: "deployments", op: "backups-create", method: "POST", path: "/deployments/:name/backups", args: []string{"name"}}, + {family: "deployments", op: "certificates-renew", method: "POST", path: "/deployments/:name/certificates/renew", args: []string{"name"}}, + {family: "deployments", op: "compose", method: "GET", path: "/deployments/:name/compose", args: []string{"name"}}, + {family: "deployments", op: "compose-mount", method: "POST", path: "/deployments/:name/compose/mount", args: []string{"name"}}, + {family: "deployments", op: "compose-unmount", method: "POST", path: "/deployments/:name/compose/unmount", args: []string{"name"}}, + {family: "deployments", op: "container-files", method: "GET", path: "/deployments/:name/container-files/:service", args: []string{"name", "service"}}, + {family: "deployments", op: "container-files-materialize", method: "POST", path: "/deployments/:name/container-files/:service/materialize", args: []string{"name", "service"}}, + {family: "deployments", op: "deploy", method: "POST", path: "/deployments/:name/deploy", args: []string{"name"}}, + {family: "deployments", op: "domains", method: "GET", path: "/deployments/:name/domains", args: []string{"name"}}, + {family: "deployments", op: "domains-create", method: "POST", path: "/deployments/:name/domains", args: []string{"name"}}, + {family: "deployments", op: "domains-delete", method: "DELETE", path: "/deployments/:name/domains/:domainId", args: []string{"name", "domainId"}}, + {family: "deployments", op: "domains-update", method: "PUT", path: "/deployments/:name/domains/:domainId", args: []string{"name", "domainId"}}, + {family: "deployments", op: "env", method: "GET", path: "/deployments/:name/env", args: []string{"name"}}, + {family: "deployments", op: "env-update", method: "PUT", path: "/deployments/:name/env", args: []string{"name"}}, + {family: "deployments", op: "files", method: "GET", path: "/deployments/:name/files", args: []string{"name"}}, + {family: "deployments", op: "files-info", method: "GET", path: "/deployments/:name/files-info", args: []string{"name"}}, + {family: "deployments", op: "files-*path-delete", method: "DELETE", path: "/deployments/:name/files/*path", args: []string{"name"}}, + {family: "deployments", op: "files-*path", method: "GET", path: "/deployments/:name/files/*path", args: []string{"name"}}, + {family: "deployments", op: "files-*path-create", method: "POST", path: "/deployments/:name/files/*path", args: []string{"name"}}, + {family: "deployments", op: "images", method: "GET", path: "/deployments/:name/images", args: []string{"name"}}, + {family: "deployments", op: "images-cleanup", method: "POST", path: "/deployments/:name/images/cleanup", args: []string{"name"}}, + {family: "deployments", op: "jobs", method: "GET", path: "/deployments/:name/jobs/:jobId", args: []string{"name", "jobId"}}, + {family: "deployments", op: "jobs-active", method: "GET", path: "/deployments/:name/jobs/active", args: []string{"name"}}, + {family: "deployments", op: "log-sources", method: "GET", path: "/deployments/:name/log-sources", args: []string{"name"}}, + {family: "deployments", op: "log-sources-update", method: "PUT", path: "/deployments/:name/log-sources", args: []string{"name"}}, + {family: "deployments", op: "logs-delete", method: "DELETE", path: "/deployments/:name/logs", args: []string{"name"}}, + {family: "deployments", op: "logs", method: "GET", path: "/deployments/:name/logs", args: []string{"name"}}, + {family: "deployments", op: "metadata", method: "PUT", path: "/deployments/:name/metadata", args: []string{"name"}}, + {family: "deployments", op: "mkdir-*path", method: "POST", path: "/deployments/:name/mkdir/*path", args: []string{"name"}}, + {family: "deployments", op: "permissions-*path", method: "PUT", path: "/deployments/:name/permissions/*path", args: []string{"name"}}, + {family: "deployments", op: "protected-mode", method: "PUT", path: "/deployments/:name/protected-mode", args: []string{"name"}}, + {family: "deployments", op: "pull", method: "POST", path: "/deployments/:name/pull", args: []string{"name"}}, + {family: "deployments", op: "rebuild", method: "POST", path: "/deployments/:name/rebuild", args: []string{"name"}}, + {family: "deployments", op: "resources", method: "GET", path: "/deployments/:name/resources", args: []string{"name"}}, + {family: "deployments", op: "restart", method: "POST", path: "/deployments/:name/restart", args: []string{"name"}}, + {family: "deployments", op: "security", method: "GET", path: "/deployments/:name/security", args: []string{"name"}}, + {family: "deployments", op: "security-update", method: "PUT", path: "/deployments/:name/security", args: []string{"name"}}, + {family: "deployments", op: "security-events", method: "GET", path: "/deployments/:name/security/events", args: []string{"name"}}, + {family: "deployments", op: "services", method: "GET", path: "/deployments/:name/services", args: []string{"name"}}, + {family: "deployments", op: "services-job", method: "POST", path: "/deployments/:name/services/:service/job", args: []string{"name", "service"}}, + {family: "deployments", op: "services-pull", method: "POST", path: "/deployments/:name/services/:service/pull", args: []string{"name", "service"}}, + {family: "deployments", op: "services-rebuild", method: "POST", path: "/deployments/:name/services/:service/rebuild", args: []string{"name", "service"}}, + {family: "deployments", op: "services-restart", method: "POST", path: "/deployments/:name/services/:service/restart", args: []string{"name", "service"}}, + {family: "deployments", op: "services-start", method: "POST", path: "/deployments/:name/services/:service/start", args: []string{"name", "service"}}, + {family: "deployments", op: "services-stop", method: "POST", path: "/deployments/:name/services/:service/stop", args: []string{"name", "service"}}, + {family: "deployments", op: "serving", method: "GET", path: "/deployments/:name/serving", args: []string{"name"}}, + {family: "deployments", op: "ssl-disable", method: "POST", path: "/deployments/:name/ssl/disable", args: []string{"name"}}, + {family: "deployments", op: "start", method: "POST", path: "/deployments/:name/start", args: []string{"name"}}, + {family: "deployments", op: "stats", method: "GET", path: "/deployments/:name/stats", args: []string{"name"}}, + {family: "deployments", op: "stop", method: "POST", path: "/deployments/:name/stop", args: []string{"name"}}, + {family: "deployments", op: "touch-*path", method: "POST", path: "/deployments/:name/touch/*path", args: []string{"name"}}, + {family: "deployments", op: "traffic", method: "GET", path: "/deployments/:name/traffic", args: []string{"name"}}, + {family: "deployments", op: "users", method: "GET", path: "/deployments/:name/users", args: []string{"name"}}, + {family: "dns", op: "providers", method: "GET", path: "/dns/providers", args: nil}, + {family: "health", op: "list", method: "GET", path: "/health", args: nil}, + {family: "images", op: "list", method: "GET", path: "/images", args: nil}, + {family: "images", op: "delete", method: "DELETE", path: "/images/:id", args: []string{"id"}}, + {family: "images", op: "cleanup", method: "POST", path: "/images/cleanup", args: nil}, + {family: "images", op: "pull", method: "POST", path: "/images/pull", args: nil}, + {family: "infrastructure", op: "list", method: "GET", path: "/infrastructure", args: nil}, + {family: "infrastructure", op: "get", method: "GET", path: "/infrastructure/:name", args: []string{"name"}}, + {family: "infrastructure", op: "logs", method: "GET", path: "/infrastructure/:name/logs", args: []string{"name"}}, + {family: "infrastructure", op: "restart", method: "POST", path: "/infrastructure/:name/restart", args: []string{"name"}}, + {family: "infrastructure", op: "start", method: "POST", path: "/infrastructure/:name/start", args: []string{"name"}}, + {family: "infrastructure", op: "stop", method: "POST", path: "/infrastructure/:name/stop", args: []string{"name"}}, + {family: "infrastructure", op: "migrate", method: "POST", path: "/infrastructure/migrate/:name", args: []string{"name"}}, + {family: "infrastructure", op: "stats", method: "GET", path: "/infrastructure/stats", args: nil}, + {family: "networks", op: "list", method: "GET", path: "/networks", args: nil}, + {family: "networks", op: "create", method: "POST", path: "/networks", args: nil}, + {family: "networks", op: "delete", method: "DELETE", path: "/networks/:name", args: []string{"name"}}, + {family: "networks", op: "connect", method: "POST", path: "/networks/:name/connect", args: []string{"name"}}, + {family: "networks", op: "disconnect", method: "POST", path: "/networks/:name/disconnect", args: []string{"name"}}, + {family: "notifications", op: "targets", method: "GET", path: "/notifications/targets", args: nil}, + {family: "notifications", op: "targets-update", method: "PUT", path: "/notifications/targets", args: nil}, + {family: "notifications", op: "test", method: "POST", path: "/notifications/test", args: nil}, + {family: "object-stores", op: "attach", method: "POST", path: "/object-stores/:name/attach", args: []string{"name"}}, + {family: "object-stores", op: "buckets", method: "GET", path: "/object-stores/:name/buckets", args: []string{"name"}}, + {family: "object-stores", op: "buckets-create", method: "POST", path: "/object-stores/:name/buckets", args: []string{"name"}}, + {family: "object-stores", op: "buckets-delete", method: "DELETE", path: "/object-stores/:name/buckets/:bucket", args: []string{"name", "bucket"}}, + {family: "object-stores", op: "objects-delete", method: "DELETE", path: "/object-stores/:name/objects", args: []string{"name"}}, + {family: "object-stores", op: "objects", method: "GET", path: "/object-stores/:name/objects", args: []string{"name"}}, + {family: "object-stores", op: "objects-create", method: "POST", path: "/object-stores/:name/objects", args: []string{"name"}}, + {family: "object-stores", op: "objects-download", method: "GET", path: "/object-stores/:name/objects/download", args: []string{"name"}}, + {family: "object-stores", op: "replicate", method: "POST", path: "/object-stores/:name/replicate", args: []string{"name"}}, + {family: "object-stores", op: "provision-managed", method: "POST", path: "/object-stores/provision-managed", args: nil}, + {family: "plans", op: "list", method: "GET", path: "/plans", args: nil}, + {family: "plans", op: "delete", method: "DELETE", path: "/plans/:id", args: []string{"id"}}, + {family: "plans", op: "get", method: "GET", path: "/plans/:id", args: []string{"id"}}, + {family: "plans", op: "apply", method: "POST", path: "/plans/:id/apply", args: []string{"id"}}, + {family: "plugins", op: "list", method: "GET", path: "/plugins", args: nil}, + {family: "plugins", op: "get", method: "GET", path: "/plugins/:name", args: []string{"name"}}, + {family: "plugins", op: "deployments", method: "POST", path: "/plugins/:name/deployments", args: []string{"name"}}, + {family: "ports", op: "list", method: "GET", path: "/ports", args: nil}, + {family: "ports", op: "kill", method: "POST", path: "/ports/:pid/kill", args: []string{"pid"}}, + {family: "proxy", op: "delete", method: "DELETE", path: "/proxy/:name", args: []string{"name"}}, + {family: "proxy", op: "setup", method: "POST", path: "/proxy/setup/:name", args: []string{"name"}}, + {family: "proxy", op: "status", method: "GET", path: "/proxy/status/:name", args: []string{"name"}}, + {family: "proxy", op: "sync", method: "POST", path: "/proxy/sync", args: nil}, + {family: "proxy", op: "vhosts", method: "GET", path: "/proxy/vhosts", args: nil}, + {family: "registries", op: "list", method: "GET", path: "/registries", args: nil}, + {family: "registries", op: "create", method: "POST", path: "/registries", args: nil}, + {family: "registries", op: "delete", method: "DELETE", path: "/registries/:slug", args: []string{"slug"}}, + {family: "registries", op: "get", method: "GET", path: "/registries/:slug", args: []string{"slug"}}, + {family: "registries", op: "update", method: "PUT", path: "/registries/:slug", args: []string{"slug"}}, + {family: "scheduler", op: "executions", method: "GET", path: "/scheduler/executions", args: nil}, + {family: "scheduler", op: "tasks", method: "GET", path: "/scheduler/tasks", args: nil}, + {family: "scheduler", op: "tasks-create", method: "POST", path: "/scheduler/tasks", args: nil}, + {family: "scheduler", op: "tasks-delete", method: "DELETE", path: "/scheduler/tasks/:id", args: []string{"id"}}, + {family: "scheduler", op: "tasks-get", method: "GET", path: "/scheduler/tasks/:id", args: []string{"id"}}, + {family: "scheduler", op: "tasks-update", method: "PUT", path: "/scheduler/tasks/:id", args: []string{"id"}}, + {family: "scheduler", op: "tasks-executions", method: "GET", path: "/scheduler/tasks/:id/executions", args: []string{"id"}}, + {family: "scheduler", op: "tasks-run", method: "POST", path: "/scheduler/tasks/:id/run", args: []string{"id"}}, + {family: "security", op: "blocked-ips", method: "GET", path: "/security/blocked-ips", args: nil}, + {family: "security", op: "blocked-ips-create", method: "POST", path: "/security/blocked-ips", args: nil}, + {family: "security", op: "blocked-ips-delete", method: "DELETE", path: "/security/blocked-ips/:ip", args: []string{"ip"}}, + {family: "security", op: "cleanup", method: "POST", path: "/security/cleanup", args: nil}, + {family: "security", op: "events", method: "GET", path: "/security/events", args: nil}, + {family: "security", op: "events-get", method: "GET", path: "/security/events/:id", args: []string{"id"}}, + {family: "security", op: "health", method: "GET", path: "/security/health", args: nil}, + {family: "security", op: "ips-events", method: "GET", path: "/security/ips/:ip/events", args: []string{"ip"}}, + {family: "security", op: "protected-routes", method: "GET", path: "/security/protected-routes", args: nil}, + {family: "security", op: "protected-routes-create", method: "POST", path: "/security/protected-routes", args: nil}, + {family: "security", op: "protected-routes-delete", method: "DELETE", path: "/security/protected-routes/:id", args: []string{"id"}}, + {family: "security", op: "protected-routes-update", method: "PUT", path: "/security/protected-routes/:id", args: []string{"id"}}, + {family: "security", op: "realtime-capture", method: "GET", path: "/security/realtime-capture", args: nil}, + {family: "security", op: "realtime-capture-update", method: "PUT", path: "/security/realtime-capture", args: nil}, + {family: "security", op: "refresh", method: "POST", path: "/security/refresh", args: nil}, + {family: "security", op: "stats", method: "GET", path: "/security/stats", args: nil}, + {family: "security", op: "whitelist", method: "GET", path: "/security/whitelist", args: nil}, + {family: "security", op: "whitelist-create", method: "POST", path: "/security/whitelist", args: nil}, + {family: "security", op: "whitelist-delete", method: "DELETE", path: "/security/whitelist/:id", args: []string{"id"}}, + {family: "server", op: "info", method: "GET", path: "/server/info", args: nil}, + {family: "server", op: "network-health", method: "GET", path: "/server/network-health", args: nil}, + {family: "settings", op: "list", method: "GET", path: "/settings", args: nil}, + {family: "settings", op: "update", method: "PUT", path: "/settings", args: nil}, + {family: "settings", op: "security", method: "PUT", path: "/settings/security", args: nil}, + {family: "setup", op: "authentication", method: "POST", path: "/setup/authentication", args: nil}, + {family: "setup", op: "complete", method: "POST", path: "/setup/complete", args: nil}, + {family: "setup", op: "info", method: "GET", path: "/setup/info", args: nil}, + {family: "setup", op: "settings", method: "POST", path: "/setup/settings", args: nil}, + {family: "setup", op: "status", method: "GET", path: "/setup/status", args: nil}, + {family: "setup", op: "validate", method: "POST", path: "/setup/validate", args: nil}, + {family: "setup", op: "verify-dns", method: "GET", path: "/setup/verify-dns", args: nil}, + {family: "source-credentials", op: "list", method: "GET", path: "/source-credentials", args: nil}, + {family: "source-credentials", op: "create", method: "POST", path: "/source-credentials", args: nil}, + {family: "source-credentials", op: "delete", method: "DELETE", path: "/source-credentials/:id", args: []string{"id"}}, + {family: "stats", op: "list", method: "GET", path: "/stats", args: nil}, + {family: "storage-credentials", op: "list", method: "GET", path: "/storage-credentials", args: nil}, + {family: "storage-credentials", op: "create", method: "POST", path: "/storage-credentials", args: nil}, + {family: "storage-credentials", op: "delete", method: "DELETE", path: "/storage-credentials/:id", args: []string{"id"}}, + {family: "storage-credentials", op: "update", method: "PUT", path: "/storage-credentials/:id", args: []string{"id"}}, + {family: "subdomain", op: "generate", method: "GET", path: "/subdomain/generate", args: nil}, + {family: "system", op: "files", method: "GET", path: "/system/files", args: nil}, + {family: "system", op: "files-info", method: "GET", path: "/system/files-info", args: nil}, + {family: "system", op: "files-*path-delete", method: "DELETE", path: "/system/files/*path", args: nil}, + {family: "system", op: "files-*path", method: "GET", path: "/system/files/*path", args: nil}, + {family: "system", op: "files-*path-create", method: "POST", path: "/system/files/*path", args: nil}, + {family: "system", op: "logs-delete", method: "DELETE", path: "/system/logs", args: nil}, + {family: "system", op: "logs", method: "GET", path: "/system/logs", args: nil}, + {family: "system", op: "logs-sources", method: "GET", path: "/system/logs/sources", args: nil}, + {family: "system", op: "mkdir-*path", method: "POST", path: "/system/mkdir/*path", args: nil}, + {family: "system", op: "permissions-*path", method: "PUT", path: "/system/permissions/*path", args: nil}, + {family: "system", op: "services", method: "GET", path: "/system/services", args: nil}, + {family: "system", op: "services-restart", method: "POST", path: "/system/services/:name/restart", args: []string{"name"}}, + {family: "system", op: "services-start", method: "POST", path: "/system/services/:name/start", args: []string{"name"}}, + {family: "system", op: "services-stop", method: "POST", path: "/system/services/:name/stop", args: []string{"name"}}, + {family: "system", op: "terminal", method: "GET", path: "/system/terminal", args: nil}, + {family: "system", op: "touch-*path", method: "POST", path: "/system/touch/*path", args: nil}, + {family: "templates", op: "list", method: "GET", path: "/templates", args: nil}, + {family: "templates", op: "compose", method: "GET", path: "/templates/:id/compose", args: []string{"id"}}, + {family: "templates", op: "generate", method: "POST", path: "/templates/:id/generate", args: []string{"id"}}, + {family: "templates", op: "categories", method: "GET", path: "/templates/categories", args: nil}, + {family: "templates", op: "infra-compose", method: "GET", path: "/templates/infra/:name/compose", args: []string{"name"}}, + {family: "templates", op: "infra-generate", method: "POST", path: "/templates/infra/:name/generate", args: []string{"name"}}, + {family: "templates", op: "refresh", method: "POST", path: "/templates/refresh", args: nil}, + {family: "traffic", op: "cleanup", method: "POST", path: "/traffic/cleanup", args: nil}, + {family: "traffic", op: "logs", method: "GET", path: "/traffic/logs", args: nil}, + {family: "traffic", op: "stats", method: "GET", path: "/traffic/stats", args: nil}, + {family: "traffic", op: "unknown-domains", method: "GET", path: "/traffic/unknown-domains", args: nil}, + {family: "users", op: "delete", method: "DELETE", path: "/users/:id", args: []string{"id"}}, + {family: "users", op: "get", method: "GET", path: "/users/:id", args: []string{"id"}}, + {family: "users", op: "update", method: "PUT", path: "/users/:id", args: []string{"id"}}, + {family: "users", op: "deployments", method: "GET", path: "/users/:id/deployments", args: []string{"id"}}, + {family: "users", op: "deployments-create", method: "POST", path: "/users/:id/deployments", args: []string{"id"}}, + {family: "users", op: "deployments-delete", method: "DELETE", path: "/users/:id/deployments/:name", args: []string{"id", "name"}}, + {family: "users", op: "deployments-update", method: "PUT", path: "/users/:id/deployments/:name", args: []string{"id", "name"}}, + {family: "users", op: "me", method: "GET", path: "/users/me", args: nil}, + {family: "users", op: "me-update", method: "PUT", path: "/users/me", args: nil}, + {family: "users", op: "me-password", method: "PUT", path: "/users/me/password", args: nil}, + {family: "volumes", op: "list", method: "GET", path: "/volumes", args: nil}, + {family: "volumes", op: "create", method: "POST", path: "/volumes", args: nil}, + {family: "volumes", op: "delete", method: "DELETE", path: "/volumes/:name", args: []string{"name"}}, + {family: "volumes", op: "prune", method: "POST", path: "/volumes/prune", args: nil}, +} diff --git a/internal/command/endpoints_test.go b/internal/command/endpoints_test.go new file mode 100644 index 0000000..4b8fc87 --- /dev/null +++ b/internal/command/endpoints_test.go @@ -0,0 +1,212 @@ +package command + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type recordedRequest struct { + method string + path string + query string + rawURI string + body map[string]any +} + +func recordingServer(t *testing.T, reply string) (*httptest.Server, *recordedRequest) { + t.Helper() + got := &recordedRequest{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got.method = r.Method + got.path = r.URL.Path + got.query = r.URL.RawQuery + got.rawURI = r.RequestURI + if raw, err := io.ReadAll(r.Body); err == nil && len(raw) > 0 { + _ = json.Unmarshal(raw, &got.body) + } + _, _ = w.Write([]byte(reply)) + })) + t.Cleanup(server.Close) + return server, got +} + +func runCLI(t *testing.T, server *httptest.Server, args ...string) (int, string, string) { + t.Helper() + t.Setenv("FLATRUN_URL", server.URL) + t.Setenv("FLATRUN_TOKEN", "secret") + var stdout, stderr bytes.Buffer + code := Run(args, &stdout, &stderr) + return code, stdout.String(), stderr.String() +} + +// The whole point of the generated table is that an agent endpoint is reachable without a +// hand-written wrapper, so this drives one that has none. +func TestGeneratedCommandCallsTheEndpoint(t *testing.T) { + server, got := recordingServer(t, `{"backups":[]}`) + + code, _, stderr := runCLI(t, server, "backups", "list", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if got.method != http.MethodGet || got.path != "/api/backups" { + t.Fatalf("called %s %s", got.method, got.path) + } +} + +func TestGeneratedCommandSubstitutesPathArguments(t *testing.T) { + server, got := recordingServer(t, `{"message":"ok"}`) + + code, _, stderr := runCLI(t, server, "certificates", "renew", "shop.example.com", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if got.path != "/api/certificates/shop.example.com/renew" { + t.Fatalf("path = %s", got.path) + } + if got.method != http.MethodPost { + t.Fatalf("method = %s", got.method) + } +} + +// A domain with a slash or a space in it must not be able to reshape the request path. +func TestGeneratedCommandEscapesPathArguments(t *testing.T) { + server, got := recordingServer(t, `{}`) + + code, _, stderr := runCLI(t, server, "certificates", "get", "one/../../admin", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + // The server decodes before it hands over URL.Path, so what matters is what went on the + // wire: one escaped segment rather than a walk up the tree. + if !strings.Contains(got.rawURI, "%2F") { + t.Fatalf("the argument was not escaped on the wire: %s", got.rawURI) + } +} + +func TestGeneratedCommandBuildsABodyFromFields(t *testing.T) { + server, got := recordingServer(t, `{"message":"saved"}`) + + code, _, stderr := runCLI(t, server, "settings", "update", "-f", "name=backups", "-f", "enabled=true", "-f", "retention=7", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if got.body["name"] != "backups" { + t.Errorf("name = %#v", got.body["name"]) + } + if got.body["enabled"] != true { + t.Errorf("enabled should be sent as a boolean, got %#v", got.body["enabled"]) + } + if got.body["retention"] != float64(7) { + t.Errorf("retention should be sent as a number, got %#v", got.body["retention"]) + } +} + +func TestGeneratedCommandPassesQueryParameters(t *testing.T) { + server, got := recordingServer(t, `{"logs":""}`) + + code, _, stderr := runCLI(t, server, "deployments", "logs", "shop", "-q", "service=web", "-q", "tail=50", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if got.path != "/api/deployments/shop/logs" { + t.Fatalf("path = %s", got.path) + } + if got.query != "service=web&tail=50" { + t.Fatalf("query = %s", got.query) + } +} + +func TestGeneratedCommandRejectsBothBodyForms(t *testing.T) { + server, _ := recordingServer(t, `{}`) + + code, _, stderr := runCLI(t, server, "settings", "update", "--data", `{"a":1}`, "-f", "b=2") + if code == 0 { + t.Fatal("sending a body two ways at once should fail") + } + if !strings.Contains(stderr, "not both") { + t.Fatalf("stderr = %s", stderr) + } +} + +func TestMissingArgumentIsRefusedBeforeAnyRequest(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) + defer server.Close() + + code, _, stderr := runCLI(t, server, "certificates", "renew") + if code == 0 { + t.Fatal("a missing argument should not be accepted") + } + if called { + t.Fatal("nothing should have been sent") + } + if !strings.Contains(stderr, "flatrun certificates renew DOMAIN") { + t.Fatalf("the usage should name the argument, got %s", stderr) + } +} + +// The singular family is hand-shaped and the plural one is generated; an operator should not +// have to know which is which. +func TestHandWrittenFamilyFallsBackToTheTable(t *testing.T) { + server, got := recordingServer(t, `{"sources":[]}`) + + code, _, stderr := runCLI(t, server, "deployment", "log-sources", "shop", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if got.path != "/api/deployments/shop/log-sources" { + t.Fatalf("path = %s", got.path) + } +} + +// An agent driving the CLI reads this instead of the docs. +func TestCommandsListsEveryEndpointAsJSON(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := Run([]string{"commands", "--json"}, &stdout, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + + var listed []struct { + Family string `json:"family"` + Op string `json:"op"` + Method string `json:"method"` + Path string `json:"path"` + Args []string `json:"args"` + Command string `json:"command"` + } + if err := json.Unmarshal(stdout.Bytes(), &listed); err != nil { + t.Fatalf("the listing must be valid JSON: %v", err) + } + if len(listed) != len(generatedEndpoints) { + t.Fatalf("listed %d of %d endpoints", len(listed), len(generatedEndpoints)) + } + for _, e := range listed { + if e.Family == "" || e.Op == "" || e.Method == "" || !strings.HasPrefix(e.Path, "/") { + t.Fatalf("incomplete entry: %+v", e) + } + } +} + +func TestEveryGeneratedCommandIsReachableAndUnique(t *testing.T) { + seen := map[string]string{} + for _, e := range generatedEndpoints { + key := e.family + " " + e.op + if previous, clash := seen[key]; clash { + t.Errorf("%q maps to both %s and %s", key, previous, e.path) + } + seen[key] = e.path + + found, ok := findEndpoint(e.family, e.op) + if !ok || found.path != e.path { + t.Errorf("%q does not dispatch back to %s", key, e.path) + } + if strings.Count(e.path, ":") != len(e.args) { + t.Errorf("%s has %d path parameters but %d arguments", e.path, strings.Count(e.path, ":"), len(e.args)) + } + } +} diff --git a/internal/command/root.go b/internal/command/root.go index 50d50d0..5964998 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -202,7 +202,14 @@ func Run(args []string, stdout, stderr io.Writer) int { return runContainer(args[1:], stdout, stderr) case "api": return runAPI(args[1:], stdout, stderr) + case "commands": + return runCommands(args[1:], stdout, stderr) default: + // Families the CLI does not shape by hand still reach the agent, through the + // generated table, so a new endpoint there is reachable here without a wrapper. + if knownFamily(args[0]) { + return runEndpoint(args[0], args[1:], stdout, stderr) + } _, _ = fmt.Fprintf(stderr, "Unknown command: %s\n\n", args[0]) usage(stderr) return 2 @@ -223,7 +230,13 @@ func usage(w io.Writer) { _, _ = fmt.Fprintln(w, " image Manage Docker images") _, _ = fmt.Fprintln(w, " container Manage containers") _, _ = fmt.Fprintln(w, " api Call any FlatRun API endpoint") + _, _ = fmt.Fprintln(w, " commands List every command, with --json for machine use") _, _ = fmt.Fprintln(w, " version Print CLI version") + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, "Resource families:") + _, _ = fmt.Fprintln(w, " "+strings.Join(families(), ", ")) + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, "Run `flatrun ` to list its commands.") } func globalFlagSet(name string, opts *globalOptions, output, debugOut io.Writer) *flag.FlagSet { @@ -557,6 +570,11 @@ func runDeployment(args []string, stdout, stderr io.Writer) int { case "images", "containers", "services": return runDeploymentRead(args[0], args[1:], stdout, stderr) default: + // Everything the agent exposes under /deployments that has no hand-shaped command + // here, so the singular family is not a smaller surface than the plural one. + if _, ok := findEndpoint("deployments", args[0]); ok { + return runEndpoint("deployments", args, stdout, stderr) + } _, _ = fmt.Fprintf(stderr, "Unknown deployment command: %s\n", args[0]) return 2 } @@ -1755,8 +1773,11 @@ func stringValue(value any) string { } func valueFlags(names ...string) map[string]bool { - result := make(map[string]bool, len(names)) + result := make(map[string]bool, len(names)*2) for _, name := range names { + // Go's flag package takes one dash or two, so both spellings have to be recognised + // here or the value gets read as the next flag. + result["-"+name] = true result["--"+name] = true } return result diff --git a/tools/gen_endpoints.py b/tools/gen_endpoints.py new file mode 100644 index 0000000..0570274 --- /dev/null +++ b/tools/gen_endpoints.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Regenerate internal/command/endpoints_gen.go from the agent's route table. + + python3 tools/gen_endpoints.py ../agent > internal/command/endpoints_gen.go + +The agent registers its routes in internal/api/server.go and publishes no machine-readable +spec, so the routes are read from that file. Adding an endpoint there and rerunning this is all +the CLI needs to reach it. +""" + +import collections +import json +import re +import sys + +GROUP_PREFIX = { + "api": "", + "protected": "", + "setupGroup": "/setup", + "guarded": "/setup", + "usersGroup": "/users", + "apiKeysGroup": "/apikeys", + "dnsGroup": "/dns", + "clusterGroup": "/cluster", +} + +# Reached by the agent's own plugins and by nginx, never by an operator. +SKIP_PREFIXES = ("/internal", "/_internal", "/security/events/ingest", "/traffic/ingest") + +# Streaming endpoints: a websocket or a long-lived follow, which the table's request/response +# shape cannot carry. +SKIP_SUFFIXES = ("/stream", "/ws", "/terminal/interactive", "/exec/interactive") + +WRITE_VERB = {"POST": "create", "PUT": "update", "PATCH": "update", "DELETE": "delete"} + + +def routes(agent_path): + src = open(agent_path + "/internal/api/server.go").read() + pattern = re.compile(r'\b(\w+)\.(GET|POST|PUT|DELETE|PATCH)\(\s*"([^"]+)"(.*?)\)\s*$', re.M) + for match in pattern.finditer(src): + group, method, path, rest = match.groups() + if group not in GROUP_PREFIX: + continue + full = GROUP_PREFIX[group] + path + if full.startswith(SKIP_PREFIXES) or full.endswith(SKIP_SUFFIXES): + continue + perm = re.search(r"auth\.(Perm\w+)", rest) + yield {"method": method, "path": full, "perm": perm.group(1) if perm else ""} + + +def op_name(method, segments): + literals = [s for s in segments if not s.startswith(":")] + params = [s for s in segments if s.startswith(":")] + if not literals: + if method == "GET": + return "get" if params else "list" + return WRITE_VERB[method] + name = "-".join(literals) + return name + + +def build(agent_path): + families = collections.defaultdict(list) + for route in routes(agent_path): + segments = route["path"].strip("/").split("/") + family, rest = segments[0], segments[1:] + families[family].append((route, rest)) + + table = [] + for family in sorted(families): + used = collections.Counter() + entries = [] + for route, rest in sorted(families[family], key=lambda r: (r[0]["path"], r[0]["method"])): + name = op_name(route["method"], rest) + entries.append([name, route, rest]) + # Several endpoints under one noun share a name: the collection and the single item, + # and the read and the write. The plainest one keeps the bare name and the rest say what + # they do, so "domains" lists them and "domains-delete" removes one. + for name, _, _ in entries: + used[name] += 1 + plainest = {} + for name, route, rest in entries: + arg_count = sum(1 for s in rest if s.startswith(":")) + if route["method"] == "GET" and arg_count < plainest.get(name, (99,))[0]: + plainest[name] = (arg_count, route["path"]) + methods = collections.defaultdict(set) + for name, route, _ in entries: + methods[name].add(route["method"]) + for entry in entries: + name, route, rest = entry + if used[name] == 1: + continue + arg_count = sum(1 for s in rest if s.startswith(":")) + if len(methods[name]) == 1: + # The same verb on the collection and on one item. Whichever is safer to type by + # mistake keeps the bare name: reading the collection, but writing to one item, + # so "renew DOMAIN" renews one and "renew-all" says what it does. + fewest = min(sum(1 for s in r.strip("/").split("/") if s.startswith(":")) + for n, rt, r in [(n, rt, rt["path"]) for n, rt, _ in entries if n == name]) + if route["method"] == "GET": + if arg_count > fewest: + entry[0] = name + "-get" + elif arg_count == fewest: + entry[0] = name + "-all" + continue + if name in plainest and plainest[name][1] == route["path"] and route["method"] == "GET": + continue + entry[0] = name + "-" + ("get" if route["method"] == "GET" else WRITE_VERB[route["method"]]) + for name, route, rest in entries: + args = [s.lstrip(":") for s in route["path"].strip("/").split("/") if s.startswith(":")] + table.append({ + "family": family, + "op": name, + "method": route["method"], + "path": route["path"], + "args": args, + "perm": route["perm"], + }) + return table + + +def main(): + args = [a for a in sys.argv[1:] if not a.startswith("--")] + if len(args) != 1: + sys.exit("usage: gen_endpoints.py PATH_TO_AGENT_CHECKOUT [--json]") + table = build(args[0]) + if "--json" in sys.argv: + print(json.dumps(table, indent=1)) + return + + out = [] + out.append("// Code generated by tools/gen_endpoints.py from the agent's route table. DO NOT EDIT.") + out.append("") + out.append("package command") + out.append("") + out.append("var generatedEndpoints = []endpoint{") + for e in table: + args = "nil" if not e["args"] else "[]string{" + ", ".join('"%s"' % a for a in e["args"]) + "}" + out.append('\t{family: "%s", op: "%s", method: "%s", path: "%s", args: %s},' + % (e["family"], e["op"], e["method"], e["path"], args)) + out.append("}") + print("\n".join(out)) + + +if __name__ == "__main__": + main() From 8e1c43caac055083344d3a0b3ed343a8b973ba3e Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 11 Aug 2026 01:19:01 +0100 Subject: [PATCH 02/10] refactor: Fold the command listing into help Listing every command was its own noun while `flatrun` and `flatrun FAMILY` already listed things. The listings themselves now take --json, so there is one way to ask what exists rather than two. --- CHANGELOG.md | 10 +++--- README.md | 26 +++++++------- docs/reference/commands.md | 35 +++++++++--------- internal/command/endpoints.go | 58 ++++++------------------------ internal/command/endpoints_test.go | 28 +++++++++++++-- internal/command/root.go | 9 ++--- 6 files changed, 75 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 214a1fe..4c5ec99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,14 @@ All notable changes to the FlatRun CLI are documented in this file. ### Added -- Every endpoint the agent exposes is now a command, as `flatrun FAMILY OPERATION [ARGS]`, covering 294 endpoints across 42 resource families including backups, certificates, databases, domains, security, scheduler, object stores, users and API keys. The command table is generated from the agent's own routes rather than written by hand, so the CLI reaches a new endpoint as soon as it is regenerated instead of trailing behind by a release. -- `flatrun commands` lists every command, and `flatrun commands --json` prints the same list with each command's method, path and arguments, so a script or an agent can discover the whole surface without reading the docs. -- `flatrun` with no arguments and `flatrun FAMILY` with no operation list what is available at that level. -- Request bodies can be built with repeatable `-f name=value` fields, or passed whole with `--data JSON` or `--data @file.json`. A field value that reads as JSON is sent as JSON, so `-f enabled=true` sends a boolean rather than a string. Query parameters go in with repeatable `-q name=value`. +- Every agent endpoint is now a command: `flatrun FAMILY OPERATION [ARGS]`, covering 294 endpoints across 42 families. The table is generated from the agent's routes, so catching up is a regeneration rather than 294 hand-written wrappers. +- `flatrun` lists the families, `flatrun FAMILY` lists its commands, and `--json` on either prints the same list with each command's method, path and arguments, for scripts and agents. +- Request bodies from repeatable `-f name=value`, or `--data JSON` / `--data @file.json`. A field value that reads as JSON is sent as JSON, so `-f enabled=true` sends a boolean. Query parameters with repeatable `-q name=value`. ### Fixed -- Single-dash flags with values (`-url`, `-token`, `-data`) were parsed as though they took no value, so the following argument was swallowed. Both spellings now work, as the flag package intends. +- `-url`, `-token` and other single-dash flags swallowed the following argument, because only the double-dash spelling was registered as taking a value. +- Path arguments were not escaped, so a value containing a slash reshaped the request path. ## [0.2.0] - 2026-06-15 diff --git a/README.md b/README.md index 80aa19c..8e45c88 100644 --- a/README.md +++ b/README.md @@ -98,19 +98,18 @@ flatrun container exec abc123 -- sh -c 'printenv | sort' ### Every other resource -The commands above are shaped by hand because they print tables worth reading. Every other -endpoint the agent exposes is reachable as `flatrun FAMILY OPERATION [ARGS]`, from a table -generated out of the agent's own routes, so a new endpoint there does not wait on a wrapper here. +The commands above are shaped by hand because they print tables worth reading. Every other agent +endpoint is `flatrun FAMILY OPERATION [ARGS]`, from a table generated out of the agent's routes. ```bash -flatrun # lists the resource families -flatrun backups # lists what can be done with backups +flatrun # the families +flatrun backups # what backups can do flatrun backups list flatrun certificates renew shop.example.com flatrun deployment logs my-api -q service=web -q tail=200 ``` -Send a body as fields or as JSON: +Bodies go in as fields or as JSON: ```bash flatrun domains create -f domain=shop.example.com -f deployment=shop @@ -118,24 +117,23 @@ flatrun settings update --data '{"backups":{"enabled":true}}' flatrun settings update --data @settings.json ``` -A field value that reads as JSON is sent as JSON, so `-f enabled=true` sends a boolean and -`-f retention=7` sends a number. Query parameters go in with `-q name=value`. +A field value that reads as JSON is sent as JSON: `-f enabled=true` sends a boolean, `-f retention=7` +sends a number. -### Driving the CLI from a script or an agent +### Driving it from a script or an agent -`flatrun commands --json` prints every command with its method, path and arguments, which is -enough for a program to discover the whole surface without reading these docs: +`--json` on any listing prints every command with its method, path and arguments: ```bash -flatrun commands --json | jq '.[] | select(.family == "backups")' -flatrun commands backups +flatrun --json | jq '.[] | select(.family == "backups")' +flatrun backups --json ``` Add `--json` to any command for the raw response. ### The raw bridge -Still available for anything the table does not cover, such as a streaming endpoint: +For anything the table does not cover, such as a streaming endpoint: ```bash flatrun api get /settings diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 697a3a5..2f7cb71 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -132,23 +132,22 @@ flatrun container delete CONTAINER_ID ## Every other resource -The families above are shaped by hand. Every other endpoint the agent exposes is reachable as -`flatrun FAMILY OPERATION [ARGS]`, from a table generated out of the agent's own routes. +The families above are shaped by hand. Every other agent endpoint is +`flatrun FAMILY OPERATION [ARGS]`, from a table generated out of the agent's routes. ```bash -flatrun # the resource families -flatrun backups # what can be done with backups +flatrun # the families +flatrun backups # what backups can do flatrun backups list flatrun backups restore BACKUP_ID flatrun certificates renew shop.example.com -flatrun scheduler tasks ``` -The operation names follow the endpoint. A collection reads as `list`, one item as `get`, and a -sub-resource keeps its own noun (`log-sources`, `actions`, `jobs`). Where a read and a write share -a path, the read keeps the plain name and the write says what it does (`log-sources` and -`log-sources-update`). Where the same verb applies to one item and to all of them, the targeted -one keeps the plain name (`certificates renew DOMAIN`, `certificates renew-all`). +Operation names follow the endpoint: a collection is `list`, one item is `get`, and a sub-resource +keeps its noun (`log-sources`, `actions`, `jobs`). Where a read and a write share a path, the read +keeps the plain name (`log-sources`, `log-sources-update`). Where a verb applies to one item or to +all of them, the targeted one is plain, so `certificates renew DOMAIN` renews one and +`certificates renew-all` renews everything. ### Sending a body @@ -159,8 +158,7 @@ flatrun settings update --data @settings.json ``` `-f name=value` is repeatable. A value that reads as JSON is sent as JSON, so `-f enabled=true` -sends a boolean, `-f retention=7` sends a number, and `-f ports=[8080]` sends an array. Use -`--data` for anything nested enough that fields get awkward; the two cannot be combined. +sends a boolean and `-f ports=[8080]` sends an array. The two body forms cannot be combined. ### Query parameters @@ -168,16 +166,17 @@ sends a boolean, `-f retention=7` sends a number, and `-f ports=[8080]` sends an flatrun deployment logs my-api -q service=web -q tail=200 ``` -## Discovering the surface +## Listing what exists ```bash -flatrun commands # every command -flatrun commands backups # one family -flatrun commands --json # the same list as JSON +flatrun # the families +flatrun backups # one family +flatrun --json # every command as JSON +flatrun backups --json # one family as JSON ``` -The JSON form gives each command's family, operation, method, path, arguments and the exact -invocation, which is what a script or an agent needs to use the CLI without reading this page. +The JSON gives each command's family, operation, method, path, arguments and exact invocation, +which is what a script or an agent needs to use the CLI without reading this page. ## Raw API diff --git a/internal/command/endpoints.go b/internal/command/endpoints.go index a871ab0..7e2f588 100644 --- a/internal/command/endpoints.go +++ b/internal/command/endpoints.go @@ -52,17 +52,6 @@ func (e endpoint) resolvePath(args []string) (string, error) { return path, nil } -func endpointsByFamily() map[string][]endpoint { - families := map[string][]endpoint{} - for _, e := range generatedEndpoints { - families[e.family] = append(families[e.family], e) - } - for _, list := range families { - sort.Slice(list, func(i, j int) bool { return list[i].op < list[j].op }) - } - return families -} - func findEndpoint(family, op string) (endpoint, bool) { for _, e := range generatedEndpoints { if e.family == family && e.op == op { @@ -136,19 +125,19 @@ func (q queryValues) Set(raw string) error { // runEndpoint dispatches `flatrun FAMILY OP ...` against the generated table. func runEndpoint(family string, args []string, stdout, stderr io.Writer) int { if len(args) == 0 { - printFamily(stdout, family) - return 0 + return listEndpoints(stdout, stderr, family, false) } switch args[0] { case "help", "-h", "--help": - printFamily(stdout, family) - return 0 + return listEndpoints(stdout, stderr, family, false) + case "--json": + return listEndpoints(stdout, stderr, family, true) } e, ok := findEndpoint(family, args[0]) if !ok { _, _ = fmt.Fprintf(stderr, "Unknown %s command: %s\n\n", family, args[0]) - printFamily(stderr, family) + listEndpoints(stderr, stderr, family, false) return 2 } @@ -214,20 +203,6 @@ func requestBody(dataArg string, fields fieldValues, e endpoint) (any, error) { return nil, nil } -func printFamily(w io.Writer, family string) { - list := endpointsByFamily()[family] - if len(list) == 0 { - _, _ = fmt.Fprintf(w, "No commands for %s\n", family) - return - } - _, _ = fmt.Fprintf(w, "flatrun %s\n\n", family) - for _, e := range list { - _, _ = fmt.Fprintf(w, " %-28s %s %s\n", e.op+" "+argNames(e), e.method, e.path) - } - _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, "Send a body with -f name=value (repeatable) or --data JSON.") -} - func argNames(e endpoint) string { names := make([]string, 0, len(e.args)) for _, arg := range e.args { @@ -236,23 +211,10 @@ func argNames(e endpoint) string { return strings.Join(names, " ") } -// runCommands prints every command the CLI can run. An agent driving this CLI needs to know -// what exists without a human reading the docs, which is what --json is for. -func runCommands(args []string, stdout, stderr io.Writer) int { - asJSON := false - family := "" - for _, arg := range args { - switch { - case arg == "--json": - asJSON = true - case strings.HasPrefix(arg, "-"): - _, _ = fmt.Fprintf(stderr, "Unknown flag: %s\n", arg) - return 2 - default: - family = arg - } - } - +// listEndpoints prints what can be run, either for one family or for all of them. The JSON form +// exists because a program driving this CLI should not have to parse help text to find out what +// it can call. +func listEndpoints(stdout, stderr io.Writer, family string, asJSON bool) int { list := make([]endpoint, 0, len(generatedEndpoints)) for _, e := range generatedEndpoints { if family == "" || e.family == family { @@ -307,6 +269,8 @@ func runCommands(args []string, stdout, stderr io.Writer) int { } _, _ = fmt.Fprintf(stdout, " %-30s %s %s\n", e.op+" "+argNames(e), e.method, e.path) } + _, _ = fmt.Fprintln(stdout) + _, _ = fmt.Fprintln(stdout, "Send a body with -f name=value (repeatable) or --data JSON.") return 0 } diff --git a/internal/command/endpoints_test.go b/internal/command/endpoints_test.go index 4b8fc87..4593057 100644 --- a/internal/command/endpoints_test.go +++ b/internal/command/endpoints_test.go @@ -164,10 +164,10 @@ func TestHandWrittenFamilyFallsBackToTheTable(t *testing.T) { } } -// An agent driving the CLI reads this instead of the docs. -func TestCommandsListsEveryEndpointAsJSON(t *testing.T) { +// A program driving the CLI reads this instead of the docs. +func TestJSONListingCoversEveryEndpoint(t *testing.T) { var stdout, stderr bytes.Buffer - if code := Run([]string{"commands", "--json"}, &stdout, &stderr); code != 0 { + if code := Run([]string{"--json"}, &stdout, &stderr); code != 0 { t.Fatalf("code=%d stderr=%s", code, stderr.String()) } @@ -192,6 +192,28 @@ func TestCommandsListsEveryEndpointAsJSON(t *testing.T) { } } +func TestJSONListingNarrowsToOneFamily(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := Run([]string{"backups", "--json"}, &stdout, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + + var listed []struct { + Family string `json:"family"` + } + if err := json.Unmarshal(stdout.Bytes(), &listed); err != nil { + t.Fatalf("the listing must be valid JSON: %v", err) + } + if len(listed) == 0 { + t.Fatal("no commands listed") + } + for _, e := range listed { + if e.Family != "backups" { + t.Fatalf("asked for one family, got %s", e.Family) + } + } +} + func TestEveryGeneratedCommandIsReachableAndUnique(t *testing.T) { seen := map[string]string{} for _, e := range generatedEndpoints { diff --git a/internal/command/root.go b/internal/command/root.go index 5964998..45598e5 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -187,6 +187,10 @@ func Run(args []string, stdout, stderr io.Writer) int { case "help", "-h", "--help": usage(stdout) return 0 + case "--json": + // The whole surface, for anything driving the CLI that should not have to read help + // text to find out what it can call. + return listEndpoints(stdout, stderr, "", true) case "version", "--version": _, _ = fmt.Fprintf(stdout, "%s\nbuild_time=%s\ngit_commit=%s\n", Version, BuildTime, GitCommit) return 0 @@ -202,8 +206,6 @@ func Run(args []string, stdout, stderr io.Writer) int { return runContainer(args[1:], stdout, stderr) case "api": return runAPI(args[1:], stdout, stderr) - case "commands": - return runCommands(args[1:], stdout, stderr) default: // Families the CLI does not shape by hand still reach the agent, through the // generated table, so a new endpoint there is reachable here without a wrapper. @@ -230,13 +232,12 @@ func usage(w io.Writer) { _, _ = fmt.Fprintln(w, " image Manage Docker images") _, _ = fmt.Fprintln(w, " container Manage containers") _, _ = fmt.Fprintln(w, " api Call any FlatRun API endpoint") - _, _ = fmt.Fprintln(w, " commands List every command, with --json for machine use") _, _ = fmt.Fprintln(w, " version Print CLI version") _, _ = fmt.Fprintln(w) _, _ = fmt.Fprintln(w, "Resource families:") _, _ = fmt.Fprintln(w, " "+strings.Join(families(), ", ")) _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, "Run `flatrun ` to list its commands.") + _, _ = fmt.Fprintln(w, "Run `flatrun ` for its commands, or add --json for all of them.") } func globalFlagSet(name string, opts *globalOptions, output, debugOut io.Writer) *flag.FlagSet { From c781af1c8c22014d1b090ed2311a10f7b1fad49a Mon Sep 17 00:00:00 2001 From: nfebe Date: Thu, 13 Aug 2026 09:47:24 +0100 Subject: [PATCH 03/10] refactor: List every command from one catalogue The hand-shaped commands and the generated ones were listed separately, so `deployment` showed seventeen commands, `deployments` showed sixty-three, and the machine-readable listing showed only the generated half. Anything reading that listing to decide what to call was working from a partial picture of what the CLI can do. Both now come from one catalogue, and the singular families reach everything their plural counterparts do. --- CHANGELOG.md | 2 +- internal/command/endpoints.go | 31 +++++++---- internal/command/endpoints_test.go | 70 ++++++++++++++++++++++++- internal/command/root.go | 35 +++++++------ internal/command/shaped.go | 83 ++++++++++++++++++++++++++++++ 5 files changed, 190 insertions(+), 31 deletions(-) create mode 100644 internal/command/shaped.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c5ec99..0cadbf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to the FlatRun CLI are documented in this file. ### Added - Every agent endpoint is now a command: `flatrun FAMILY OPERATION [ARGS]`, covering 294 endpoints across 42 families. The table is generated from the agent's routes, so catching up is a regeneration rather than 294 hand-written wrappers. -- `flatrun` lists the families, `flatrun FAMILY` lists its commands, and `--json` on either prints the same list with each command's method, path and arguments, for scripts and agents. +- `flatrun` lists the families, `flatrun FAMILY` lists its commands, and `--json` on either prints the same list with each command's method, path and arguments, for scripts and agents. One listing covers both the hand-shaped commands and the generated ones, and the singular families reach everything their plural counterparts do, so `deployment log-sources` works. - Request bodies from repeatable `-f name=value`, or `--data JSON` / `--data @file.json`. A field value that reads as JSON is sent as JSON, so `-f enabled=true` sends a boolean. Query parameters with repeatable `-q name=value`. ### Fixed diff --git a/internal/command/endpoints.go b/internal/command/endpoints.go index 7e2f588..c54e1e8 100644 --- a/internal/command/endpoints.go +++ b/internal/command/endpoints.go @@ -24,16 +24,13 @@ type endpoint struct { method string path string args []string + // Set on the commands written by hand: extra arguments they take beyond the path, and the + // marker that says the generated table is not the whole story for this one. + flags string + shaped bool } -// command is what an operator types, and what `flatrun commands` prints. -func (e endpoint) command() string { - parts := []string{"flatrun", e.family, e.op} - for _, arg := range e.args { - parts = append(parts, strings.ToUpper(arg)) - } - return strings.Join(parts, " ") -} +func (e endpoint) command() string { return invocation(e) } func (e endpoint) writes() bool { return e.method != "GET" } @@ -173,6 +170,17 @@ func runEndpoint(family string, args []string, stdout, stderr io.Writer) int { return runClientCommand(cmd, args[1:], stdout, stderr) } +// runAliasedEndpoint reaches a plural family's endpoint from its singular name, so the two are +// not different surfaces. +func runAliasedEndpoint(plural, singular string, args []string, stdout, stderr io.Writer) int { + if _, ok := findEndpoint(plural, args[0]); ok { + return runEndpoint(plural, args, stdout, stderr) + } + _, _ = fmt.Fprintf(stderr, "Unknown %s command: %s\n\n", singular, args[0]) + listEndpoints(stderr, stderr, singular, false) + return 2 +} + func requestBody(dataArg string, fields fieldValues, e endpoint) (any, error) { if dataArg != "" && len(fields) > 0 { return nil, fmt.Errorf("use --data or -f, not both") @@ -216,7 +224,7 @@ func argNames(e endpoint) string { // it can call. func listEndpoints(stdout, stderr io.Writer, family string, asJSON bool) int { list := make([]endpoint, 0, len(generatedEndpoints)) - for _, e := range generatedEndpoints { + for _, e := range catalogue() { if family == "" || e.family == family { list = append(list, e) } @@ -240,6 +248,7 @@ func listEndpoints(stdout, stderr io.Writer, family string, asJSON bool) int { Path string `json:"path"` Args []string `json:"args"` Command string `json:"command"` + Shaped bool `json:"shaped,omitempty"` } out := make([]wire, 0, len(list)) for _, e := range list { @@ -247,7 +256,7 @@ func listEndpoints(stdout, stderr io.Writer, family string, asJSON bool) int { if args == nil { args = []string{} } - out = append(out, wire{e.family, e.op, e.method, e.path, args, e.command()}) + out = append(out, wire{e.family, e.op, e.method, e.path, args, e.command(), e.shaped}) } encoded, err := json.MarshalIndent(out, "", " ") if err != nil { @@ -267,7 +276,7 @@ func listEndpoints(stdout, stderr io.Writer, family string, asJSON bool) int { current = e.family _, _ = fmt.Fprintln(stdout, e.family) } - _, _ = fmt.Fprintf(stdout, " %-30s %s %s\n", e.op+" "+argNames(e), e.method, e.path) + _, _ = fmt.Fprintf(stdout, " %-38s %s %s\n", strings.TrimSpace(e.op+" "+argNames(e)+" "+e.flags), e.method, e.path) } _, _ = fmt.Fprintln(stdout) _, _ = fmt.Fprintln(stdout, "Send a body with -f name=value (repeatable) or --data JSON.") diff --git a/internal/command/endpoints_test.go b/internal/command/endpoints_test.go index 4593057..8c26120 100644 --- a/internal/command/endpoints_test.go +++ b/internal/command/endpoints_test.go @@ -182,8 +182,8 @@ func TestJSONListingCoversEveryEndpoint(t *testing.T) { if err := json.Unmarshal(stdout.Bytes(), &listed); err != nil { t.Fatalf("the listing must be valid JSON: %v", err) } - if len(listed) != len(generatedEndpoints) { - t.Fatalf("listed %d of %d endpoints", len(listed), len(generatedEndpoints)) + if len(listed) != len(catalogue()) { + t.Fatalf("listed %d of %d commands", len(listed), len(catalogue())) } for _, e := range listed { if e.Family == "" || e.Op == "" || e.Method == "" || !strings.HasPrefix(e.Path, "/") { @@ -214,6 +214,72 @@ func TestJSONListingNarrowsToOneFamily(t *testing.T) { } } +// A caller reading the JSON must see the hand-shaped commands too, or it only learns half of +// what the CLI can do and reaches for the raw endpoint instead. +func TestJSONListingIncludesHandShapedCommands(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := Run([]string{"--json"}, &stdout, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + + var listed []struct { + Family string `json:"family"` + Op string `json:"op"` + Command string `json:"command"` + Shaped bool `json:"shaped"` + } + if err := json.Unmarshal(stdout.Bytes(), &listed); err != nil { + t.Fatal(err) + } + + found := false + for _, e := range listed { + if e.Family == "deployment" && e.Op == "exec" { + found = true + if !e.Shaped { + t.Error("a hand-shaped command should say so") + } + if !strings.Contains(e.Command, "-- COMMAND") { + t.Errorf("the invocation should show what it takes, got %q", e.Command) + } + } + } + if !found { + t.Error("deployment exec is missing from the listing") + } +} + +// Both names for one resource reach the same operations. +func TestSingularFamilyListsWhatThePluralDoes(t *testing.T) { + var singular, plural, stderr bytes.Buffer + if code := Run([]string{"deployment", "--json"}, &singular, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if code := Run([]string{"deployments", "--json"}, &plural, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + + ops := func(raw []byte) map[string]bool { + var listed []struct { + Op string `json:"op"` + } + if err := json.Unmarshal(raw, &listed); err != nil { + t.Fatal(err) + } + out := map[string]bool{} + for _, e := range listed { + out[e.Op] = true + } + return out + } + + for op := range ops(plural.Bytes()) { + if !ops(singular.Bytes())[op] { + t.Errorf("deployments %s is not reachable as deployment %s", op, op) + } + } +} + func TestEveryGeneratedCommandIsReachableAndUnique(t *testing.T) { seen := map[string]string{} for _, e := range generatedEndpoints { diff --git a/internal/command/root.go b/internal/command/root.go index 45598e5..700fb05 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -541,11 +541,14 @@ func runHealth(args []string, stdout, stderr io.Writer) int { func runDeployment(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { - _, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment ") - return 2 + return listEndpoints(stdout, stderr, "deployment", false) } switch args[0] { + case "help", "-h", "--help": + return listEndpoints(stdout, stderr, "deployment", false) + case "--json": + return listEndpoints(stdout, stderr, "deployment", true) case "list": return runDeploymentList(args[1:], stdout, stderr) case "info", "get": @@ -571,13 +574,7 @@ func runDeployment(args []string, stdout, stderr io.Writer) int { case "images", "containers", "services": return runDeploymentRead(args[0], args[1:], stdout, stderr) default: - // Everything the agent exposes under /deployments that has no hand-shaped command - // here, so the singular family is not a smaller surface than the plural one. - if _, ok := findEndpoint("deployments", args[0]); ok { - return runEndpoint("deployments", args, stdout, stderr) - } - _, _ = fmt.Fprintf(stderr, "Unknown deployment command: %s\n", args[0]) - return 2 + return runAliasedEndpoint("deployments", "deployment", args, stdout, stderr) } } @@ -1092,11 +1089,14 @@ func runDeploymentDeploy(args []string, stdout, stderr io.Writer) int { func runImage(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { - _, _ = fmt.Fprintln(stderr, "Usage: flatrun image ") - return 2 + return listEndpoints(stdout, stderr, "image", false) } switch args[0] { + case "help", "-h", "--help": + return listEndpoints(stdout, stderr, "image", false) + case "--json": + return listEndpoints(stdout, stderr, "image", true) case "list": return runImageList(args[1:], stdout, stderr) case "pull": @@ -1104,8 +1104,7 @@ func runImage(args []string, stdout, stderr io.Writer) int { case "delete": return runImageDelete(args[1:], stdout, stderr) default: - _, _ = fmt.Fprintf(stderr, "Unknown image command: %s\n", args[0]) - return 2 + return runAliasedEndpoint("images", "image", args, stdout, stderr) } } @@ -1152,11 +1151,14 @@ func runImageDelete(args []string, stdout, stderr io.Writer) int { func runContainer(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { - _, _ = fmt.Fprintln(stderr, "Usage: flatrun container ") - return 2 + return listEndpoints(stdout, stderr, "container", false) } switch args[0] { + case "help", "-h", "--help": + return listEndpoints(stdout, stderr, "container", false) + case "--json": + return listEndpoints(stdout, stderr, "container", true) case "list": return runContainerList(args[1:], stdout, stderr) case "start", "stop", "restart": @@ -1166,8 +1168,7 @@ func runContainer(args []string, stdout, stderr io.Writer) int { case "delete": return runContainerDelete(args[1:], stdout, stderr) default: - _, _ = fmt.Fprintf(stderr, "Unknown container command: %s\n", args[0]) - return 2 + return runAliasedEndpoint("containers", "container", args, stdout, stderr) } } diff --git a/internal/command/shaped.go b/internal/command/shaped.go new file mode 100644 index 0000000..a61feaf --- /dev/null +++ b/internal/command/shaped.go @@ -0,0 +1,83 @@ +package command + +import "strings" + +// shapedCommands are the commands written by hand rather than taken from the route table, +// because they render a table, take flags shaped for the task, or read a command after `--`. +// They are listed here so that one catalogue answers what the CLI can do: a caller reading the +// JSON listing sees them alongside the generated ones instead of only half the surface. +var shapedCommands = []endpoint{ + {family: "deployment", op: "list", method: "GET", path: "/deployments"}, + {family: "deployment", op: "info", method: "GET", path: "/deployments/:name", args: []string{"name"}}, + {family: "deployment", op: "get", method: "GET", path: "/deployments/:name", args: []string{"name"}}, + {family: "deployment", op: "create", method: "POST", path: "/deployments", flags: "--image --port --host-port"}, + {family: "deployment", op: "delete", method: "DELETE", path: "/deployments/:name", args: []string{"name"}}, + {family: "deployment", op: "start", method: "POST", path: "/deployments/:name/manage", args: []string{"name"}}, + {family: "deployment", op: "stop", method: "POST", path: "/deployments/:name/manage", args: []string{"name"}}, + {family: "deployment", op: "restart", method: "POST", path: "/deployments/:name/manage", args: []string{"name"}}, + {family: "deployment", op: "rebuild", method: "POST", path: "/deployments/:name/manage", args: []string{"name"}}, + {family: "deployment", op: "deploy", method: "POST", path: "/deployments/:name/deploy", args: []string{"name"}, flags: "--operation --pull"}, + {family: "deployment", op: "pull", method: "POST", path: "/deployments/:name/pull", args: []string{"name"}, flags: "--only-latest"}, + {family: "deployment", op: "images", method: "GET", path: "/deployments/:name/images", args: []string{"name"}}, + {family: "deployment", op: "containers", method: "GET", path: "/deployments/:name/containers", args: []string{"name"}}, + {family: "deployment", op: "services", method: "GET", path: "/deployments/:name/services", args: []string{"name"}}, + {family: "deployment", op: "actions", method: "GET", path: "/deployments/:name/actions", args: []string{"name"}}, + {family: "deployment", op: "action", method: "POST", path: "/deployments/:name/actions/:actionId", args: []string{"name", "actionId"}}, + {family: "deployment", op: "exec", method: "POST", path: "/deployments/:name/exec", args: []string{"name"}, flags: "[SERVICE] -- COMMAND"}, + {family: "deployment", op: "image set", method: "PUT", path: "/deployments/:name/compose", args: []string{"name", "service", "image"}, flags: "--deploy --operation"}, + + {family: "image", op: "list", method: "GET", path: "/images"}, + {family: "image", op: "pull", method: "POST", path: "/images/pull", args: []string{"image"}, flags: "--credential-id"}, + {family: "image", op: "delete", method: "DELETE", path: "/images/:id", args: []string{"id"}}, + + {family: "container", op: "list", method: "GET", path: "/containers"}, + {family: "container", op: "start", method: "POST", path: "/containers/:id/start", args: []string{"id"}}, + {family: "container", op: "stop", method: "POST", path: "/containers/:id/stop", args: []string{"id"}}, + {family: "container", op: "restart", method: "POST", path: "/containers/:id/restart", args: []string{"id"}}, + {family: "container", op: "exec", method: "POST", path: "/containers/:id/exec", args: []string{"id"}, flags: "-- COMMAND"}, + {family: "container", op: "delete", method: "DELETE", path: "/containers/:id", args: []string{"id"}}, +} + +// catalogue is every command the CLI can run: the hand-shaped ones and the generated ones. The +// singular family a hand-shaped command lives under also reaches its plural counterpart, so both +// names appear rather than only the half a reader happened to look under. +func catalogue() []endpoint { + all := make([]endpoint, 0, len(shapedCommands)+len(generatedEndpoints)) + seen := map[string]bool{} + for _, e := range shapedCommands { + e.shaped = true + all = append(all, e) + seen[e.family+" "+e.op] = true + } + for _, e := range generatedEndpoints { + if seen[e.family+" "+e.op] { + continue + } + all = append(all, e) + // A hand-shaped singular family reaches every operation of its plural one. + if singular, ok := shapedAlias[e.family]; ok && !seen[singular+" "+e.op] { + alias := e + alias.family = singular + all = append(all, alias) + } + } + return all +} + +// shapedAlias maps a generated family onto the singular name the hand-shaped commands use. +var shapedAlias = map[string]string{ + "deployments": "deployment", + "images": "image", + "containers": "container", +} + +func invocation(e endpoint) string { + parts := []string{"flatrun", e.family, e.op} + for _, arg := range e.args { + parts = append(parts, strings.ToUpper(arg)) + } + if e.flags != "" { + parts = append(parts, e.flags) + } + return strings.Join(parts, " ") +} From 50ce9f91341c7134b04aad6055262b6220591878 Mon Sep 17 00:00:00 2001 From: nfebe Date: Thu, 13 Aug 2026 10:47:29 +0100 Subject: [PATCH 04/10] feat: Read the agent's description of its own API Generated commands passed fields through without knowing what an endpoint accepted, so a typo came back as a 400 naming nothing, and every answer printed as raw JSON because nothing said which fields made a row. Where an agent describes itself, a mistyped field or query parameter now fails before the request and says what was probably meant, asking for help on a command lists the fields it takes and the permission it needs, and answers print as tables laid out from the types the agent returns rather than from a renderer written per endpoint. The description is read from the agent being talked to and cached per agent, so it matches the instance rather than whatever was true when the CLI was built. An agent too old to describe itself behaves exactly as before. --- CHANGELOG.md | 2 + internal/command/endpoints.go | 64 +++++++++ internal/command/schema.go | 215 +++++++++++++++++++++++++++++ internal/command/schema_test.go | 215 +++++++++++++++++++++++++++++ internal/flatrun/client.go | 4 + internal/spec/fetch.go | 76 +++++++++++ internal/spec/spec.go | 235 ++++++++++++++++++++++++++++++++ 7 files changed, 811 insertions(+) create mode 100644 internal/command/schema.go create mode 100644 internal/command/schema_test.go create mode 100644 internal/spec/fetch.go create mode 100644 internal/spec/spec.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cadbf2..74b6d3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ All notable changes to the FlatRun CLI are documented in this file. - `flatrun` lists the families, `flatrun FAMILY` lists its commands, and `--json` on either prints the same list with each command's method, path and arguments, for scripts and agents. One listing covers both the hand-shaped commands and the generated ones, and the singular families reach everything their plural counterparts do, so `deployment log-sources` works. - Request bodies from repeatable `-f name=value`, or `--data JSON` / `--data @file.json`. A field value that reads as JSON is sent as JSON, so `-f enabled=true` sends a boolean. Query parameters with repeatable `-q name=value`. +- Commands read the agent's own description of its API where the agent serves one, so a mistyped field or query parameter fails before the request with the name it was probably meant to be, `COMMAND --help` lists the fields an endpoint takes and the permission it needs, and answers print as tables laid out from the types the agent returns. An agent that does not describe itself behaves as before. + ### Fixed - `-url`, `-token` and other single-dash flags swallowed the following argument, because only the double-dash spelling was registered as taking a value. diff --git a/internal/command/endpoints.go b/internal/command/endpoints.go index c54e1e8..30be0ef 100644 --- a/internal/command/endpoints.go +++ b/internal/command/endpoints.go @@ -11,8 +11,10 @@ import ( "sort" "strconv" "strings" + "time" "github.com/flatrun/cli/internal/flatrun" + "github.com/flatrun/cli/internal/spec" ) // endpoint is one agent API endpoint, reachable as `flatrun FAMILY OP ARGS...`. The table is @@ -131,6 +133,10 @@ func runEndpoint(family string, args []string, stdout, stderr io.Writer) int { return listEndpoints(stdout, stderr, family, true) } + if len(args) > 1 && (args[1] == "--help" || args[1] == "-h") { + return explainEndpoint(family, args[0], stdout, stderr) + } + e, ok := findEndpoint(family, args[0]) if !ok { _, _ = fmt.Fprintf(stderr, "Unknown %s command: %s\n\n", family, args[0]) @@ -141,6 +147,9 @@ func runEndpoint(family string, args []string, stdout, stderr io.Writer) int { fields := fieldValues{} query := queryValues{} dataArg := "" + var api *spec.Spec + var operation spec.Operation + described := false cmd := clientCommand{ name: family + " " + e.op, @@ -157,6 +166,23 @@ func runEndpoint(family string, args []string, stdout, stderr io.Writer) int { if err != nil { return nil, err } + + // The agent's own description of this endpoint, when it offers one. Checking here + // turns a 400 with no explanation into a message naming the field. + api = spec.Load(ctx, client, client.BaseURL()) + if api != nil { + if op, found := api.Operation(e.method, e.path); found { + operation = op + described = true + if err := checkFields(api, op, fields); err != nil { + return nil, err + } + if err := checkQuery(api, op, query); err != nil { + return nil, err + } + } + } + if len(query) > 0 { path += "?" + url.Values(query).Encode() } @@ -166,6 +192,13 @@ func runEndpoint(family string, args []string, stdout, stderr io.Writer) int { } return client.Do(ctx, e.method, path, payload) }, + render: func(w io.Writer, data []byte) error { + if described && renderTable(w, api, operation, data) { + return nil + } + printResponse(w, true, data, "") + return nil + }, } return runClientCommand(cmd, args[1:], stdout, stderr) } @@ -181,6 +214,37 @@ func runAliasedEndpoint(plural, singular string, args []string, stdout, stderr i return 2 } +// explainEndpoint answers "what does this take" from the agent's own description, rather than +// leaving a caller to read the agent's source or guess at field names. +func explainEndpoint(family, op string, stdout, stderr io.Writer) int { + e, ok := findEndpoint(family, op) + if !ok { + _, _ = fmt.Fprintf(stderr, "Unknown %s command: %s\n", family, op) + return 2 + } + + client, err := clientFromOptions(globalOptions{Timeout: 30 * time.Second}) + if err != nil { + _, _ = fmt.Fprintln(stdout, invocation(e)) + _, _ = fmt.Fprintln(stdout, "\nConnect to an agent to see the fields this takes.") + return 0 + } + + api := spec.Load(context.Background(), client, client.BaseURL()) + if api == nil { + _, _ = fmt.Fprintln(stdout, invocation(e)) + _, _ = fmt.Fprintln(stdout, "\nThis agent does not describe its API, so the fields are not known here.") + return 0 + } + operation, found := api.Operation(e.method, e.path) + if !found { + _, _ = fmt.Fprintln(stdout, invocation(e)) + return 0 + } + describeEndpoint(stdout, api, e, operation) + return 0 +} + func requestBody(dataArg string, fields fieldValues, e endpoint) (any, error) { if dataArg != "" && len(fields) > 0 { return nil, fmt.Errorf("use --data or -f, not both") diff --git a/internal/command/schema.go b/internal/command/schema.go new file mode 100644 index 0000000..5433730 --- /dev/null +++ b/internal/command/schema.go @@ -0,0 +1,215 @@ +package command + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/flatrun/cli/internal/spec" +) + +// checkFields refuses a body field the endpoint does not accept, and a required one that is +// missing, before anything is sent. The agent would refuse both, but a 400 from a server names +// neither the field it wanted nor the one it did not understand. +func checkFields(api *spec.Spec, op spec.Operation, sent fieldValues) error { + fields := api.Fields(op) + if len(fields) == 0 { + return nil + } + + known := make(map[string]bool, len(fields)) + names := make([]string, 0, len(fields)) + for _, field := range fields { + known[field.Name] = true + names = append(names, field.Name) + } + + var unknown []string + for name := range sent { + if !known[name] { + unknown = append(unknown, name) + } + } + sort.Strings(unknown) + if len(unknown) > 0 { + message := fmt.Sprintf("unknown field %s", strings.Join(unknown, ", ")) + if suggestion := closest(unknown[0], names); suggestion != "" { + message += fmt.Sprintf(". Did you mean %s?", suggestion) + } else { + message += fmt.Sprintf(". This endpoint takes: %s", strings.Join(names, ", ")) + } + return fmt.Errorf("%s", message) + } + + var missing []string + for _, field := range fields { + if field.Required { + if _, ok := sent[field.Name]; !ok { + missing = append(missing, field.Name) + } + } + } + if len(missing) > 0 && len(sent) > 0 { + return fmt.Errorf("missing required field %s", strings.Join(missing, ", ")) + } + return nil +} + +func checkQuery(api *spec.Spec, op spec.Operation, sent queryValues) error { + accepted := api.QueryParams(op) + if len(accepted) == 0 || len(sent) == 0 { + return nil + } + known := make(map[string]bool, len(accepted)) + for _, name := range accepted { + known[name] = true + } + for name := range sent { + if known[name] { + continue + } + message := fmt.Sprintf("unknown query parameter %s", name) + if suggestion := closest(name, accepted); suggestion != "" { + return fmt.Errorf("%s. Did you mean %s?", message, suggestion) + } + return fmt.Errorf("%s. This endpoint reads: %s", message, strings.Join(accepted, ", ")) + } + return nil +} + +// closest is the nearest accepted name to what was typed, when one is near enough that it was +// probably meant. +func closest(typed string, candidates []string) string { + best, bestDistance := "", len(typed)/2+1 + for _, candidate := range candidates { + if d := distance(typed, candidate); d <= bestDistance { + best, bestDistance = candidate, d + } + } + return best +} + +func distance(a, b string) int { + previous := make([]int, len(b)+1) + current := make([]int, len(b)+1) + for j := range previous { + previous[j] = j + } + for i := 1; i <= len(a); i++ { + current[0] = i + for j := 1; j <= len(b); j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + current[j] = min(previous[j]+1, min(current[j-1]+1, previous[j-1]+cost)) + } + copy(previous, current) + } + return previous[len(b)] +} + +// describeEndpoint prints what an endpoint takes, which is the answer to "what do I put in -f". +func describeEndpoint(w io.Writer, api *spec.Spec, e endpoint, op spec.Operation) { + _, _ = fmt.Fprintln(w, invocation(e)) + if op.Permission != "" { + _, _ = fmt.Fprintf(w, "Needs %s\n", op.Permission) + } + + if fields := api.Fields(op); len(fields) > 0 { + _, _ = fmt.Fprintln(w, "\nFields, given as -f name=value:") + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + for _, field := range fields { + required := "" + if field.Required { + required = "required" + } + _, _ = fmt.Fprintf(tw, " %s\t%s\t%s\t%s\n", field.Name, field.Type, required, field.Help) + } + _ = tw.Flush() + } + + if query := api.QueryParams(op); len(query) > 0 { + _, _ = fmt.Fprintf(w, "\nQuery parameters, given as -q name=value:\n %s\n", strings.Join(query, ", ")) + } +} + +// renderTable prints an endpoint's answer as columns when the type it returns says which fields +// make a row. Nothing here is written per endpoint: the layout comes from the description. +func renderTable(w io.Writer, api *spec.Spec, op spec.Operation, data []byte) bool { + table, ok := api.Table(op) + if !ok { + return false + } + + var body map[string]json.RawMessage + if err := json.Unmarshal(data, &body); err != nil { + return false + } + raw, ok := body[table.Key] + if !ok { + return false + } + var rows []map[string]any + if err := json.Unmarshal(raw, &rows); err != nil { + return false + } + if len(rows) == 0 { + _, _ = fmt.Fprintf(w, "No %s\n", table.Key) + return true + } + + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + headings := make([]string, 0, len(table.Columns)) + for _, column := range table.Columns { + headings = append(headings, strings.ToUpper(strings.ReplaceAll(column, "_", " "))) + } + _, _ = fmt.Fprintln(tw, strings.Join(headings, "\t")) + for _, row := range rows { + cells := make([]string, 0, len(table.Columns)) + for _, column := range table.Columns { + cells = append(cells, cell(row[column])) + } + _, _ = fmt.Fprintln(tw, strings.Join(cells, "\t")) + } + _ = tw.Flush() + return true +} + +func cell(value any) string { + switch typed := value.(type) { + case nil: + return "-" + case string: + if at, err := time.Parse(time.RFC3339, typed); err == nil { + return at.Local().Format("2006-01-02 15:04") + } + return typed + case bool: + if typed { + return "yes" + } + return "no" + case float64: + if typed == float64(int64(typed)) { + return strconv.FormatInt(int64(typed), 10) + } + return strconv.FormatFloat(typed, 'f', 2, 64) + case []any: + parts := make([]string, 0, len(typed)) + for _, item := range typed { + parts = append(parts, cell(item)) + } + return strings.Join(parts, ",") + } + encoded, err := json.Marshal(value) + if err != nil { + return "-" + } + return string(encoded) +} diff --git a/internal/command/schema_test.go b/internal/command/schema_test.go new file mode 100644 index 0000000..0d31622 --- /dev/null +++ b/internal/command/schema_test.go @@ -0,0 +1,215 @@ +package command + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// A slice of a real agent's description: one endpoint with a required field, and one that +// answers with rows and says which of their fields make columns. +const testSpec = `{ + "openapi": "3.1.0", + "info": {"version": "0.4.0"}, + "paths": { + "/api/backups": { + "post": { + "operationId": "post-backups", + "x-permission": "backups:write", + "requestBody": {"required": true, "content": {"application/json": { + "schema": {"$ref": "#/components/schemas/backup.CreateBackupRequest"}}}}, + "responses": {"200": {"description": "Success"}} + }, + "get": { + "operationId": "get-backups", + "parameters": [{"name": "deployment", "in": "query", "schema": {"type": "string"}}], + "responses": {"200": {"description": "Success", "content": {"application/json": { + "schema": {"$ref": "#/components/schemas/api.BackupListResponse"}}}}} + } + } + }, + "components": {"schemas": { + "backup.CreateBackupRequest": { + "type": "object", + "required": ["deployment_name"], + "x-property-order": ["deployment_name", "description"], + "properties": { + "deployment_name": {"type": "string"}, + "description": {"type": "string"} + } + }, + "api.BackupListResponse": { + "type": "object", + "x-property-order": ["backups"], + "properties": {"backups": {"type": "array", "items": {"$ref": "#/components/schemas/backup.Backup"}}} + }, + "backup.Backup": { + "type": "object", + "x-columns": ["id", "deployment_name", "status"], + "x-property-order": ["id", "deployment_name", "status", "path"], + "properties": { + "id": {"type": "string"}, + "deployment_name": {"type": "string"}, + "status": {"type": "string"}, + "path": {"type": "string"} + } + } + }} +}` + +// describingServer answers the description on /openapi.json and the given reply everywhere else. +func describingServer(t *testing.T, reply string) (*httptest.Server, *recordedRequest) { + t.Helper() + got := &recordedRequest{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/openapi.json") { + _, _ = w.Write([]byte(testSpec)) + return + } + got.method = r.Method + got.path = r.URL.Path + got.query = r.URL.RawQuery + _, _ = w.Write([]byte(reply)) + })) + t.Cleanup(server.Close) + return server, got +} + +// Each test gets its own cache, or one test's description would answer another's question. +func isolateCache(t *testing.T) { + t.Helper() + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + t.Setenv("HOME", filepath.Join(dir, "home")) + if err := os.MkdirAll(filepath.Join(dir, "home"), 0755); err != nil { + t.Fatal(err) + } +} + +func TestUnknownFieldIsRefusedBeforeSending(t *testing.T) { + isolateCache(t) + server, got := describingServer(t, `{"message":"created"}`) + + code, _, stderr := runCLI(t, server, "backups", "create", "-f", "deployment_nmae=shop") + if code == 0 { + t.Fatal("a field the endpoint does not take should fail") + } + if got.path != "" { + t.Fatalf("nothing should have been sent, but %s %s was", got.method, got.path) + } + if !strings.Contains(stderr, "deployment_name") { + t.Fatalf("the error should name the field that was meant, got %s", stderr) + } +} + +func TestKnownFieldsAreSent(t *testing.T) { + isolateCache(t) + server, got := describingServer(t, `{"message":"created"}`) + + code, _, stderr := runCLI(t, server, "backups", "create", "-f", "deployment_name=shop", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if got.path != "/api/backups" { + t.Fatalf("path = %s", got.path) + } +} + +func TestUnknownQueryParameterIsRefused(t *testing.T) { + isolateCache(t) + server, got := describingServer(t, `{"backups":[]}`) + + code, _, stderr := runCLI(t, server, "backups", "list", "-q", "deploymnet=shop") + if code == 0 { + t.Fatal("a query parameter the endpoint does not read should fail") + } + if got.path != "" { + t.Fatal("nothing should have been sent") + } + if !strings.Contains(stderr, "deployment") { + t.Fatalf("the error should name the parameter that was meant, got %s", stderr) + } +} + +// The layout comes from the description, so an endpoint nobody wrote a renderer for still prints +// as a table. +func TestAnswerIsRenderedFromTheDescription(t *testing.T) { + isolateCache(t) + reply := `{"backups":[ + {"id":"b-1","deployment_name":"shop","status":"complete","path":"/srv/b-1"}, + {"id":"b-2","deployment_name":"blog","status":"failed","path":"/srv/b-2"}]}` + server, _ := describingServer(t, reply) + + code, stdout, stderr := runCLI(t, server, "backups", "list") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if !strings.Contains(stdout, "ID") || !strings.Contains(stdout, "DEPLOYMENT NAME") { + t.Fatalf("expected column headings, got:\n%s", stdout) + } + if !strings.Contains(stdout, "b-1") || !strings.Contains(stdout, "complete") { + t.Fatalf("expected the rows, got:\n%s", stdout) + } + if strings.Contains(stdout, "/srv/b-1") { + t.Fatalf("a field that is not a column should stay out of the table:\n%s", stdout) + } +} + +func TestJSONStillWinsOverTheTable(t *testing.T) { + isolateCache(t) + server, _ := describingServer(t, `{"backups":[{"id":"b-1","deployment_name":"shop","status":"complete"}]}`) + + code, stdout, stderr := runCLI(t, server, "backups", "list", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + var decoded map[string]any + if err := json.Unmarshal([]byte(stdout), &decoded); err != nil { + t.Fatalf("--json should print the raw answer, got:\n%s", stdout) + } +} + +func TestHelpShowsWhatAnEndpointTakes(t *testing.T) { + isolateCache(t) + server, _ := describingServer(t, `{}`) + + code, stdout, stderr := runCLI(t, server, "backups", "create", "--help") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + for _, want := range []string{"deployment_name", "required", "description", "backups:write"} { + if !strings.Contains(stdout, want) { + t.Errorf("help should mention %q, got:\n%s", want, stdout) + } + } +} + +// An older agent that cannot describe itself still has to work. +func TestCommandsWorkWithoutADescription(t *testing.T) { + isolateCache(t) + got := &recordedRequest{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/openapi.json") { + w.WriteHeader(http.StatusNotFound) + return + } + got.path = r.URL.Path + _, _ = w.Write([]byte(`{"backups":[]}`)) + })) + defer server.Close() + + var stdout, stderr bytes.Buffer + t.Setenv("FLATRUN_URL", server.URL) + t.Setenv("FLATRUN_TOKEN", "secret") + if code := Run([]string{"backups", "list", "-q", "anything=goes"}, &stdout, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if got.path != "/api/backups" { + t.Fatalf("path = %s", got.path) + } +} diff --git a/internal/flatrun/client.go b/internal/flatrun/client.go index 1a94ceb..43609b3 100644 --- a/internal/flatrun/client.go +++ b/internal/flatrun/client.go @@ -69,6 +69,10 @@ func New(baseURL, token string, timeout time.Duration, insecure bool) *Client { } } +// BaseURL is the agent this client talks to, which is what a cache of that agent's API +// description is keyed on. +func (c *Client) BaseURL() string { return c.baseURL } + func (c *Client) Health(ctx context.Context) ([]byte, error) { return c.Do(ctx, http.MethodGet, "/health", nil) } diff --git a/internal/spec/fetch.go b/internal/spec/fetch.go new file mode 100644 index 0000000..e446b1b --- /dev/null +++ b/internal/spec/fetch.go @@ -0,0 +1,76 @@ +package spec + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "time" +) + +// Fetcher reads the description from an agent. +type Fetcher interface { + Do(ctx context.Context, method, path string, payload any) ([]byte, error) +} + +// cacheTTL is how long a cached description is used before asking again. An agent's API changes +// when it is upgraded, which is rare, so this only has to be short enough that an upgrade is +// noticed the same day. +const cacheTTL = 12 * time.Hour + +// Load returns the description of the agent at baseURL, from the cache when it is recent enough +// and from the agent otherwise. An agent too old to describe itself returns nil rather than an +// error: the CLI still works without a description, it just cannot check anything. +func Load(ctx context.Context, client Fetcher, baseURL string) *Spec { + path := cachePath(baseURL) + if raw, err := readFresh(path); err == nil { + if parsed, err := Parse(raw); err == nil { + return parsed + } + } + + raw, err := client.Do(ctx, "GET", "/openapi.json", nil) + if err != nil { + return nil + } + parsed, err := Parse(raw) + if err != nil { + return nil + } + write(path, raw) + return parsed +} + +func readFresh(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if time.Since(info.ModTime()) > cacheTTL { + return nil, os.ErrDeadlineExceeded + } + return os.ReadFile(path) +} + +func write(path string, raw []byte) { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return + } + _ = os.WriteFile(path, raw, 0644) +} + +// cachePath keys the cache by agent, since one profile's agent is not another's. +func cachePath(baseURL string) string { + sum := sha256.Sum256([]byte(baseURL)) + name := hex.EncodeToString(sum[:8]) + ".json" + + if dir, err := os.UserCacheDir(); err == nil { + return filepath.Join(dir, "flatrun", "api", name) + } + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(os.TempDir(), "flatrun-api-"+name) + } + return filepath.Join(home, ".flatrun", "cache", "api", name) +} diff --git a/internal/spec/spec.go b/internal/spec/spec.go new file mode 100644 index 0000000..b74237e --- /dev/null +++ b/internal/spec/spec.go @@ -0,0 +1,235 @@ +// Package spec reads the description an agent serves of its own API: what an endpoint accepts, +// what it requires, and which fields of its answer are worth putting in a table. Without it the +// CLI can only pass fields through and hope; with it a mistyped field fails before the request. +package spec + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +type Spec struct { + OpenAPI string `json:"openapi"` + Info Info `json:"info"` + Paths map[string]map[string]Operation `json:"paths"` + Components Components `json:"components"` +} + +type Info struct { + Version string `json:"version"` +} + +type Components struct { + Schemas map[string]*Schema `json:"schemas"` +} + +type Operation struct { + OperationID string `json:"operationId"` + Parameters []Parameter `json:"parameters"` + RequestBody *RequestBody `json:"requestBody"` + Responses map[string]struct { + Content map[string]struct { + Schema *Schema `json:"schema"` + } `json:"content"` + } `json:"responses"` + Permission string `json:"x-permission"` +} + +type Parameter struct { + Name string `json:"name"` + In string `json:"in"` + Required bool `json:"required"` + Schema *Schema `json:"schema"` +} + +type RequestBody struct { + Required bool `json:"required"` + Content map[string]struct { + Schema *Schema `json:"schema"` + } `json:"content"` +} + +type Schema struct { + Ref string `json:"$ref"` + Type string `json:"type"` + Format string `json:"format"` + Items *Schema `json:"items"` + Properties map[string]*Schema `json:"properties"` + PropertyOrder []string `json:"x-property-order"` + Columns []string `json:"x-columns"` + Required []string `json:"required"` + Description string `json:"description"` + AdditionalProperties *Schema `json:"additionalProperties"` +} + +func Parse(raw []byte) (*Spec, error) { + var s Spec + if err := json.Unmarshal(raw, &s); err != nil { + return nil, fmt.Errorf("the agent's API description could not be read: %w", err) + } + if len(s.Paths) == 0 { + return nil, fmt.Errorf("the agent's API description contains no endpoints") + } + return &s, nil +} + +// Resolve follows a $ref to the schema it names. +func (s *Spec) Resolve(schema *Schema) *Schema { + seen := 0 + for schema != nil && schema.Ref != "" && seen < 10 { + name := strings.TrimPrefix(schema.Ref, "#/components/schemas/") + schema = s.Components.Schemas[name] + seen++ + } + return schema +} + +// Operation finds the description of one endpoint by method and path, where the path is the one +// the CLI holds (`/deployments/:name`) rather than the spec's (`/api/deployments/{name}`). +func (s *Spec) Operation(method, path string) (Operation, bool) { + op, ok := s.Paths[specPath(path)][strings.ToLower(method)] + return op, ok +} + +func specPath(path string) string { + segments := strings.Split(path, "/") + for i, segment := range segments { + if strings.HasPrefix(segment, ":") { + segments[i] = "{" + strings.TrimPrefix(segment, ":") + "}" + } + } + return "/api" + strings.Join(segments, "/") +} + +// Field is one thing an endpoint accepts in its body. +type Field struct { + Name string + Type string + Required bool + Help string +} + +// Fields are the body fields of an endpoint, in the order they are declared, so `--help` reads +// the way the type does rather than alphabetically. +func (s *Spec) Fields(op Operation) []Field { + if op.RequestBody == nil { + return nil + } + content, ok := op.RequestBody.Content["application/json"] + if !ok { + return nil + } + schema := s.Resolve(content.Schema) + if schema == nil || len(schema.Properties) == 0 { + return nil + } + + required := map[string]bool{} + for _, name := range schema.Required { + required[name] = true + } + + order := schema.PropertyOrder + if len(order) == 0 { + for name := range schema.Properties { + order = append(order, name) + } + sort.Strings(order) + } + + fields := make([]Field, 0, len(order)) + for _, name := range order { + property := s.Resolve(schema.Properties[name]) + if property == nil { + continue + } + fields = append(fields, Field{ + Name: name, + Type: typeName(property), + Required: required[name], + Help: property.Description, + }) + } + return fields +} + +func typeName(schema *Schema) string { + switch schema.Type { + case "array": + if schema.Items != nil && schema.Items.Type != "" { + return schema.Items.Type + " list" + } + return "list" + case "": + return "any" + case "string": + if schema.Format == "date-time" { + return "timestamp" + } + return "string" + } + return schema.Type +} + +// QueryParams are the query keys an endpoint reads. +func (s *Spec) QueryParams(op Operation) []string { + var names []string + for _, p := range op.Parameters { + if p.In == "query" { + names = append(names, p.Name) + } + } + sort.Strings(names) + return names +} + +// Table describes how to lay out an endpoint's answer: the key holding the rows, and the columns +// the type asked for. Absent when the endpoint answers with something that is not a list of +// objects, which is most of them until their handlers return declared types. +type Table struct { + Key string + Columns []string +} + +func (s *Spec) Table(op Operation) (Table, bool) { + ok200, ok := op.Responses["200"] + if !ok { + return Table{}, false + } + content, ok := ok200.Content["application/json"] + if !ok { + return Table{}, false + } + schema := s.Resolve(content.Schema) + if schema == nil { + return Table{}, false + } + + // The rows are a list somewhere in the answer, next to whatever else it carries. + for _, name := range propertyOrder(schema) { + property := schema.Properties[name] + if property == nil || property.Type != "array" || property.Items == nil { + continue + } + row := s.Resolve(property.Items) + if row == nil || len(row.Columns) == 0 { + continue + } + return Table{Key: name, Columns: row.Columns}, true + } + return Table{}, false +} + +func propertyOrder(schema *Schema) []string { + if len(schema.PropertyOrder) > 0 { + return schema.PropertyOrder + } + names := make([]string, 0, len(schema.Properties)) + for name := range schema.Properties { + names = append(names, name) + } + sort.Strings(names) + return names +} From bbe4d5fe5cff7f85719bf4a8dcea131e8b4c4d91 Mon Sep 17 00:00:00 2001 From: nfebe Date: Thu, 13 Aug 2026 23:25:51 +0100 Subject: [PATCH 05/10] feat: Render an answer by its shape, not by its resource The agent now says which shape an endpoint answers in, so a list of certificates and a list of backups take the same path through the CLI and an endpoint converted tomorrow prints properly with no change here. Columns come from the row's own type, capped so a wide one stays readable, with the whole answer still under --json. --- internal/command/endpoints.go | 2 +- internal/command/schema.go | 75 ++++++++++++++++++++++++++++----- internal/command/schema_test.go | 18 +++++--- internal/spec/spec.go | 57 +++++++++++++++++-------- 4 files changed, 118 insertions(+), 34 deletions(-) diff --git a/internal/command/endpoints.go b/internal/command/endpoints.go index 30be0ef..c614202 100644 --- a/internal/command/endpoints.go +++ b/internal/command/endpoints.go @@ -193,7 +193,7 @@ func runEndpoint(family string, args []string, stdout, stderr io.Writer) int { return client.Do(ctx, e.method, path, payload) }, render: func(w io.Writer, data []byte) error { - if described && renderTable(w, api, operation, data) { + if described && renderAnswer(w, api, operation, data) { return nil } printResponse(w, true, data, "") diff --git a/internal/command/schema.go b/internal/command/schema.go index 5433730..140a62e 100644 --- a/internal/command/schema.go +++ b/internal/command/schema.go @@ -139,10 +139,10 @@ func describeEndpoint(w io.Writer, api *spec.Spec, e endpoint, op spec.Operation } } -// renderTable prints an endpoint's answer as columns when the type it returns says which fields -// make a row. Nothing here is written per endpoint: the layout comes from the description. -func renderTable(w io.Writer, api *spec.Spec, op spec.Operation, data []byte) bool { - table, ok := api.Table(op) +// renderAnswer lays out a response according to the shape the agent says it answers in. Nothing +// here knows a resource: a list of certificates and a list of backups take the same path. +func renderAnswer(w io.Writer, api *spec.Spec, op spec.Operation, data []byte) bool { + shape, ok := api.Shape(op) if !ok { return false } @@ -151,7 +151,27 @@ func renderTable(w io.Writer, api *spec.Spec, op spec.Operation, data []byte) bo if err := json.Unmarshal(data, &body); err != nil { return false } - raw, ok := body[table.Key] + + switch shape.Kind { + case "list": + return renderList(w, shape, body) + case "item": + return renderItem(w, shape, body) + case "message": + var message struct { + Message string `json:"message"` + } + if err := json.Unmarshal(data, &message); err != nil || message.Message == "" { + return false + } + _, _ = fmt.Fprintln(w, message.Message) + return true + } + return false +} + +func renderList(w io.Writer, shape spec.Shape, body map[string]json.RawMessage) bool { + raw, ok := body[shape.Key] if !ok { return false } @@ -160,19 +180,30 @@ func renderTable(w io.Writer, api *spec.Spec, op spec.Operation, data []byte) bo return false } if len(rows) == 0 { - _, _ = fmt.Fprintf(w, "No %s\n", table.Key) + _, _ = fmt.Fprintln(w, "None") return true } + columns := shape.Columns + if len(columns) == 0 { + // A row whose type named nothing still prints, using whatever fits in a cell. + for name, value := range rows[0] { + if _, nested := value.(map[string]any); !nested { + columns = append(columns, name) + } + } + sort.Strings(columns) + } + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - headings := make([]string, 0, len(table.Columns)) - for _, column := range table.Columns { + headings := make([]string, 0, len(columns)) + for _, column := range columns { headings = append(headings, strings.ToUpper(strings.ReplaceAll(column, "_", " "))) } _, _ = fmt.Fprintln(tw, strings.Join(headings, "\t")) for _, row := range rows { - cells := make([]string, 0, len(table.Columns)) - for _, column := range table.Columns { + cells := make([]string, 0, len(columns)) + for _, column := range columns { cells = append(cells, cell(row[column])) } _, _ = fmt.Fprintln(tw, strings.Join(cells, "\t")) @@ -181,6 +212,30 @@ func renderTable(w io.Writer, api *spec.Spec, op spec.Operation, data []byte) bo return true } +func renderItem(w io.Writer, shape spec.Shape, body map[string]json.RawMessage) bool { + raw, ok := body[shape.Key] + if !ok { + return false + } + var fields map[string]any + if err := json.Unmarshal(raw, &fields); err != nil { + return false + } + + names := make([]string, 0, len(fields)) + for name := range fields { + names = append(names, name) + } + sort.Strings(names) + + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + for _, name := range names { + _, _ = fmt.Fprintf(tw, "%s\t%s\n", strings.ReplaceAll(name, "_", " "), cell(fields[name])) + } + _ = tw.Flush() + return true +} + func cell(value any) string { switch typed := value.(type) { case nil: diff --git a/internal/command/schema_test.go b/internal/command/schema_test.go index 0d31622..68ecc43 100644 --- a/internal/command/schema_test.go +++ b/internal/command/schema_test.go @@ -29,7 +29,7 @@ const testSpec = `{ "operationId": "get-backups", "parameters": [{"name": "deployment", "in": "query", "schema": {"type": "string"}}], "responses": {"200": {"description": "Success", "content": {"application/json": { - "schema": {"$ref": "#/components/schemas/api.BackupListResponse"}}}}} + "schema": {"$ref": "#/components/schemas/api.ListOfBackup"}}}}} } } }, @@ -43,10 +43,14 @@ const testSpec = `{ "description": {"type": "string"} } }, - "api.BackupListResponse": { + "api.ListOfBackup": { "type": "object", - "x-property-order": ["backups"], - "properties": {"backups": {"type": "array", "items": {"$ref": "#/components/schemas/backup.Backup"}}} + "x-render": "list", + "x-property-order": ["items", "total"], + "properties": { + "items": {"type": "array", "items": {"$ref": "#/components/schemas/backup.Backup"}}, + "total": {"type": "integer"} + } }, "backup.Backup": { "type": "object", @@ -140,7 +144,9 @@ func TestUnknownQueryParameterIsRefused(t *testing.T) { // as a table. func TestAnswerIsRenderedFromTheDescription(t *testing.T) { isolateCache(t) - reply := `{"backups":[ + reply := `{"items":[ + {"id":"b-1","deployment_name":"shop","status":"complete","path":"/srv/b-1"}, + {"id":"b-2","deployment_name":"blog","status":"failed","path":"/srv/b-2"}],"total":2,"backups":[ {"id":"b-1","deployment_name":"shop","status":"complete","path":"/srv/b-1"}, {"id":"b-2","deployment_name":"blog","status":"failed","path":"/srv/b-2"}]}` server, _ := describingServer(t, reply) @@ -162,7 +168,7 @@ func TestAnswerIsRenderedFromTheDescription(t *testing.T) { func TestJSONStillWinsOverTheTable(t *testing.T) { isolateCache(t) - server, _ := describingServer(t, `{"backups":[{"id":"b-1","deployment_name":"shop","status":"complete"}]}`) + server, _ := describingServer(t, `{"items":[{"id":"b-1","deployment_name":"shop","status":"complete"}],"total":1}`) code, stdout, stderr := runCLI(t, server, "backups", "list", "--json") if code != 0 { diff --git a/internal/spec/spec.go b/internal/spec/spec.go index b74237e..fd77d69 100644 --- a/internal/spec/spec.go +++ b/internal/spec/spec.go @@ -59,6 +59,7 @@ type Schema struct { Properties map[string]*Schema `json:"properties"` PropertyOrder []string `json:"x-property-order"` Columns []string `json:"x-columns"` + Render string `json:"x-render"` Required []string `json:"required"` Description string `json:"description"` AdditionalProperties *Schema `json:"additionalProperties"` @@ -185,41 +186,63 @@ func (s *Spec) QueryParams(op Operation) []string { return names } -// Table describes how to lay out an endpoint's answer: the key holding the rows, and the columns -// the type asked for. Absent when the endpoint answers with something that is not a list of -// objects, which is most of them until their handlers return declared types. -type Table struct { - Key string +// Shape is how an endpoint's answer is meant to be presented. The agent says which of a small +// set of shapes it answers in, so the CLI lays out a collection of anything the same way rather +// than knowing a resource. +type Shape struct { + // Kind is "list", "item", "message", or empty when the endpoint does not say. + Kind string + // Key holds the rows or the thing, within the answer. + Key string + // Columns are the fields of a row worth showing, in the order the type declares them. Columns []string } -func (s *Spec) Table(op Operation) (Table, bool) { +// maxColumns keeps a wide type readable. Everything is still in --json. +const maxColumns = 6 + +func (s *Spec) Shape(op Operation) (Shape, bool) { ok200, ok := op.Responses["200"] if !ok { - return Table{}, false + return Shape{}, false } content, ok := ok200.Content["application/json"] if !ok { - return Table{}, false + return Shape{}, false } schema := s.Resolve(content.Schema) - if schema == nil { - return Table{}, false + if schema == nil || schema.Render == "" { + return Shape{}, false } - // The rows are a list somewhere in the answer, next to whatever else it carries. + shape := Shape{Kind: schema.Render} for _, name := range propertyOrder(schema) { property := schema.Properties[name] - if property == nil || property.Type != "array" || property.Items == nil { + if property == nil { continue } - row := s.Resolve(property.Items) - if row == nil || len(row.Columns) == 0 { - continue + switch schema.Render { + case "list": + if property.Type != "array" || property.Items == nil { + continue + } + shape.Key = name + if row := s.Resolve(property.Items); row != nil { + shape.Columns = row.Columns + if len(shape.Columns) > maxColumns { + shape.Columns = shape.Columns[:maxColumns] + } + } + return shape, true + case "item": + shape.Key = name + return shape, true } - return Table{Key: name, Columns: row.Columns}, true } - return Table{}, false + if schema.Render == "message" { + return shape, true + } + return Shape{}, false } func propertyOrder(schema *Schema) []string { From 87f1933888e4a7eee1b42e7f40b925a1ff37a4b6 Mon Sep 17 00:00:00 2001 From: nfebe Date: Thu, 13 Aug 2026 23:50:44 +0100 Subject: [PATCH 06/10] fix: Print a list of names as names Every collection was rendered as a table, so a list of domains came out as a column with a heading over it. A row that is a plain value, or a type with one column, prints one per line, which is also what pipes into the next command. --- internal/command/endpoints.go | 2 -- internal/command/schema.go | 39 +++++++++++++++++++++++---------- internal/command/schema_test.go | 27 +++++++++++++++++++++++ internal/spec/fetch.go | 13 +++++------ internal/spec/spec.go | 23 +++++++------------ 5 files changed, 67 insertions(+), 37 deletions(-) diff --git a/internal/command/endpoints.go b/internal/command/endpoints.go index c614202..19aadce 100644 --- a/internal/command/endpoints.go +++ b/internal/command/endpoints.go @@ -214,8 +214,6 @@ func runAliasedEndpoint(plural, singular string, args []string, stdout, stderr i return 2 } -// explainEndpoint answers "what does this take" from the agent's own description, rather than -// leaving a caller to read the agent's source or guess at field names. func explainEndpoint(family, op string, stdout, stderr io.Writer) int { e, ok := findEndpoint(family, op) if !ok { diff --git a/internal/command/schema.go b/internal/command/schema.go index 140a62e..6ffe40b 100644 --- a/internal/command/schema.go +++ b/internal/command/schema.go @@ -13,9 +13,8 @@ import ( "github.com/flatrun/cli/internal/spec" ) -// checkFields refuses a body field the endpoint does not accept, and a required one that is -// missing, before anything is sent. The agent would refuse both, but a 400 from a server names -// neither the field it wanted nor the one it did not understand. +// checkFields refuses an unknown or missing field before anything is sent, since a 400 names +// neither the field the agent wanted nor the one it did not understand. func checkFields(api *spec.Spec, op spec.Operation, sent fieldValues) error { fields := api.Fields(op) if len(fields) == 0 { @@ -82,8 +81,7 @@ func checkQuery(api *spec.Spec, op spec.Operation, sent queryValues) error { return nil } -// closest is the nearest accepted name to what was typed, when one is near enough that it was -// probably meant. +// closest is the nearest accepted name to what was typed, when one is near enough to have been meant. func closest(typed string, candidates []string) string { best, bestDistance := "", len(typed)/2+1 for _, candidate := range candidates { @@ -114,7 +112,6 @@ func distance(a, b string) int { return previous[len(b)] } -// describeEndpoint prints what an endpoint takes, which is the answer to "what do I put in -f". func describeEndpoint(w io.Writer, api *spec.Spec, e endpoint, op spec.Operation) { _, _ = fmt.Fprintln(w, invocation(e)) if op.Permission != "" { @@ -139,8 +136,8 @@ func describeEndpoint(w io.Writer, api *spec.Spec, e endpoint, op spec.Operation } } -// renderAnswer lays out a response according to the shape the agent says it answers in. Nothing -// here knows a resource: a list of certificates and a list of backups take the same path. +// renderAnswer lays out a response by the shape the agent answers in, so a list of certificates +// and a list of backups take the same path. func renderAnswer(w io.Writer, api *spec.Spec, op spec.Operation, data []byte) bool { shape, ok := api.Shape(op) if !ok { @@ -175,18 +172,30 @@ func renderList(w io.Writer, shape spec.Shape, body map[string]json.RawMessage) if !ok { return false } - var rows []map[string]any - if err := json.Unmarshal(raw, &rows); err != nil { + var values []any + if err := json.Unmarshal(raw, &values); err != nil { return false } - if len(rows) == 0 { + if len(values) == 0 { _, _ = fmt.Fprintln(w, "None") return true } + // A column heading over a single column of names is furniture. Names print as names. + rows := make([]map[string]any, 0, len(values)) + for _, value := range values { + row, ok := value.(map[string]any) + if !ok { + for _, value := range values { + _, _ = fmt.Fprintln(w, cell(value)) + } + return true + } + rows = append(rows, row) + } + columns := shape.Columns if len(columns) == 0 { - // A row whose type named nothing still prints, using whatever fits in a cell. for name, value := range rows[0] { if _, nested := value.(map[string]any); !nested { columns = append(columns, name) @@ -194,6 +203,12 @@ func renderList(w io.Writer, shape spec.Shape, body map[string]json.RawMessage) } sort.Strings(columns) } + if len(columns) == 1 { + for _, row := range rows { + _, _ = fmt.Fprintln(w, cell(row[columns[0]])) + } + return true + } tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) headings := make([]string, 0, len(columns)) diff --git a/internal/command/schema_test.go b/internal/command/schema_test.go index 68ecc43..1ab6238 100644 --- a/internal/command/schema_test.go +++ b/internal/command/schema_test.go @@ -219,3 +219,30 @@ func TestCommandsWorkWithoutADescription(t *testing.T) { t.Fatalf("path = %s", got.path) } } + +// A column heading over one column of names is furniture, so names print as names. +func TestNamesPrintAsLinesNotATable(t *testing.T) { + isolateCache(t) + spec := strings.Replace(testSpec, + `"x-columns": ["id", "deployment_name", "status"]`, + `"x-columns": ["id"]`, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/openapi.json") { + _, _ = w.Write([]byte(spec)) + return + } + _, _ = w.Write([]byte(`{"items":[{"id":"b-1"},{"id":"b-2"}],"total":2}`)) + })) + defer server.Close() + + code, stdout, stderr := runCLI(t, server, "backups", "list") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if strings.Contains(stdout, "ID") { + t.Fatalf("one column needs no heading, got:\n%s", stdout) + } + if stdout != "b-1\nb-2\n" { + t.Fatalf("expected one name per line, got:\n%q", stdout) + } +} diff --git a/internal/spec/fetch.go b/internal/spec/fetch.go index e446b1b..7cdafd5 100644 --- a/internal/spec/fetch.go +++ b/internal/spec/fetch.go @@ -9,19 +9,16 @@ import ( "time" ) -// Fetcher reads the description from an agent. type Fetcher interface { Do(ctx context.Context, method, path string, payload any) ([]byte, error) } -// cacheTTL is how long a cached description is used before asking again. An agent's API changes -// when it is upgraded, which is rare, so this only has to be short enough that an upgrade is -// noticed the same day. +// An agent's API changes only when it is upgraded, so this need only be short enough that an +// upgrade is noticed the same day. const cacheTTL = 12 * time.Hour -// Load returns the description of the agent at baseURL, from the cache when it is recent enough -// and from the agent otherwise. An agent too old to describe itself returns nil rather than an -// error: the CLI still works without a description, it just cannot check anything. +// Load returns nil for an agent too old to describe itself: the CLI works without a description, +// it just cannot check anything. func Load(ctx context.Context, client Fetcher, baseURL string) *Spec { path := cachePath(baseURL) if raw, err := readFresh(path); err == nil { @@ -60,7 +57,7 @@ func write(path string, raw []byte) { _ = os.WriteFile(path, raw, 0644) } -// cachePath keys the cache by agent, since one profile's agent is not another's. +// cachePath keys the cache by agent. func cachePath(baseURL string) string { sum := sha256.Sum256([]byte(baseURL)) name := hex.EncodeToString(sum[:8]) + ".json" diff --git a/internal/spec/spec.go b/internal/spec/spec.go index fd77d69..a999f5d 100644 --- a/internal/spec/spec.go +++ b/internal/spec/spec.go @@ -1,6 +1,5 @@ -// Package spec reads the description an agent serves of its own API: what an endpoint accepts, -// what it requires, and which fields of its answer are worth putting in a table. Without it the -// CLI can only pass fields through and hope; with it a mistyped field fails before the request. +// Package spec reads the description an agent serves of its own API. Without it the CLI can only +// pass fields through and hope. package spec import ( @@ -87,8 +86,7 @@ func (s *Spec) Resolve(schema *Schema) *Schema { return schema } -// Operation finds the description of one endpoint by method and path, where the path is the one -// the CLI holds (`/deployments/:name`) rather than the spec's (`/api/deployments/{name}`). +// Operation takes the path as the CLI holds it, `/deployments/:name`, not as the spec writes it. func (s *Spec) Operation(method, path string) (Operation, bool) { op, ok := s.Paths[specPath(path)][strings.ToLower(method)] return op, ok @@ -112,8 +110,7 @@ type Field struct { Help string } -// Fields are the body fields of an endpoint, in the order they are declared, so `--help` reads -// the way the type does rather than alphabetically. +// Fields are an endpoint's body fields in declaration order, so help reads the way the type does. func (s *Spec) Fields(op Operation) []Field { if op.RequestBody == nil { return nil @@ -186,15 +183,11 @@ func (s *Spec) QueryParams(op Operation) []string { return names } -// Shape is how an endpoint's answer is meant to be presented. The agent says which of a small -// set of shapes it answers in, so the CLI lays out a collection of anything the same way rather -// than knowing a resource. +// Shape is how an endpoint's answer is presented: "list", "item", "message", or empty when the +// agent does not say. Key holds the rows or the thing within the answer. type Shape struct { - // Kind is "list", "item", "message", or empty when the endpoint does not say. - Kind string - // Key holds the rows or the thing, within the answer. - Key string - // Columns are the fields of a row worth showing, in the order the type declares them. + Kind string + Key string Columns []string } From 9bdcf571be79ac0b92fccde42cbbed5289c79bb5 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 15 Aug 2026 10:40:55 +0100 Subject: [PATCH 07/10] fix: Take the command table from the agent's description The CLI read the agent's source itself to build its table, which is a second reading that can disagree with the first. It now reads what the agent publishes, which brought in thirteen endpoints it had never reached: nine proxy DNS ones registered in a function of their own, and four more registered under an empty path. An endpoint whose last segment holds a whole file path was unreachable in a different way: that segment was treated as a literal, so the command was named after it and took no argument for it. Those now take the path as an argument, and its separators survive the request while everything between them is still escaped. A required field is checked whatever carries the body, so --data is held to the same requirement as -f, and an endpoint that requires something no longer accepts an empty body. --- internal/command/endpoints.go | 36 +++--- internal/command/endpoints_gen.go | 76 ++++++++----- internal/command/endpoints_test.go | 31 ++++- internal/command/schema.go | 12 +- internal/command/schema_test.go | 30 +++++ tools/gen_endpoints.py | 175 ++++++++++++++--------------- 6 files changed, 222 insertions(+), 138 deletions(-) diff --git a/internal/command/endpoints.go b/internal/command/endpoints.go index 19aadce..2f13487 100644 --- a/internal/command/endpoints.go +++ b/internal/command/endpoints.go @@ -34,8 +34,6 @@ type endpoint struct { func (e endpoint) command() string { return invocation(e) } -func (e endpoint) writes() bool { return e.method != "GET" } - // resolvePath substitutes the positional arguments into the path parameters. func (e endpoint) resolvePath(args []string) (string, error) { if len(args) != len(e.args) { @@ -46,11 +44,25 @@ func (e endpoint) resolvePath(args []string) (string, error) { if args[i] == "" { return "", fmt.Errorf("%s cannot be empty: %s", strings.ToUpper(name), e.command()) } - path = strings.Replace(path, ":"+name, url.PathEscape(args[i]), 1) + if strings.Contains(path, ":"+name) { + path = strings.Replace(path, ":"+name, url.PathEscape(args[i]), 1) + continue + } + // A wildcard stands for the rest of the path, so its separators are structure and only + // what sits between them is escaped. + path = strings.Replace(path, "*"+name, escapeSubPath(args[i]), 1) } return path, nil } +func escapeSubPath(value string) string { + segments := strings.Split(value, "/") + for i, segment := range segments { + segments[i] = url.PathEscape(segment) + } + return strings.Join(segments, "/") +} + func findEndpoint(family, op string) (endpoint, bool) { for _, e := range generatedEndpoints { if e.family == family && e.op == op { @@ -174,9 +186,6 @@ func runEndpoint(family string, args []string, stdout, stderr io.Writer) int { if op, found := api.Operation(e.method, e.path); found { operation = op described = true - if err := checkFields(api, op, fields); err != nil { - return nil, err - } if err := checkQuery(api, op, query); err != nil { return nil, err } @@ -186,10 +195,15 @@ func runEndpoint(family string, args []string, stdout, stderr io.Writer) int { if len(query) > 0 { path += "?" + url.Values(query).Encode() } - payload, err := requestBody(dataArg, fields, e) + payload, err := requestBody(dataArg, fields) if err != nil { return nil, err } + if described { + if err := checkFields(api, operation, payload); err != nil { + return nil, err + } + } return client.Do(ctx, e.method, path, payload) }, render: func(w io.Writer, data []byte) error { @@ -243,7 +257,7 @@ func explainEndpoint(family, op string, stdout, stderr io.Writer) int { return 0 } -func requestBody(dataArg string, fields fieldValues, e endpoint) (any, error) { +func requestBody(dataArg string, fields fieldValues) (any, error) { if dataArg != "" && len(fields) > 0 { return nil, fmt.Errorf("use --data or -f, not both") } @@ -265,11 +279,7 @@ func requestBody(dataArg string, fields fieldValues, e endpoint) (any, error) { if len(fields) > 0 { return map[string]any(fields), nil } - if e.writes() { - // A write with no body is normal here: restarting a deployment or renewing a - // certificate carries nothing. - return nil, nil - } + // A write with no body is normal: restarting a deployment carries nothing. return nil, nil } diff --git a/internal/command/endpoints_gen.go b/internal/command/endpoints_gen.go index c837c62..ebcca0e 100644 --- a/internal/command/endpoints_gen.go +++ b/internal/command/endpoints_gen.go @@ -1,4 +1,4 @@ -// Code generated by tools/gen_endpoints.py from the agent's route table. DO NOT EDIT. +// Code generated by tools/gen_endpoints.py from the agent's API description. DO NOT EDIT. package command @@ -18,6 +18,8 @@ var generatedEndpoints = []endpoint{ {family: "ai", op: "sessions-approve", method: "POST", path: "/ai/sessions/:id/approve", args: []string{"id"}}, {family: "ai", op: "sessions-messages", method: "POST", path: "/ai/sessions/:id/messages", args: []string{"id"}}, {family: "ai", op: "status", method: "GET", path: "/ai/status", args: nil}, + {family: "apikeys", op: "list", method: "GET", path: "/apikeys", args: nil}, + {family: "apikeys", op: "create", method: "POST", path: "/apikeys", args: nil}, {family: "apikeys", op: "delete", method: "DELETE", path: "/apikeys/:id", args: []string{"id"}}, {family: "apikeys", op: "get", method: "GET", path: "/apikeys/:id", args: []string{"id"}}, {family: "apikeys", op: "update", method: "PUT", path: "/apikeys/:id", args: []string{"id"}}, @@ -34,19 +36,19 @@ var generatedEndpoints = []endpoint{ {family: "backup-destinations", op: "test", method: "POST", path: "/backup-destinations/test", args: nil}, {family: "backups", op: "list", method: "GET", path: "/backups", args: nil}, {family: "backups", op: "create", method: "POST", path: "/backups", args: nil}, + {family: "backups", op: "jobs", method: "GET", path: "/backups/jobs", args: nil}, + {family: "backups", op: "jobs-get", method: "GET", path: "/backups/jobs/:id", args: []string{"id"}}, {family: "backups", op: "delete", method: "DELETE", path: "/backups/:id", args: []string{"id"}}, {family: "backups", op: "get", method: "GET", path: "/backups/:id", args: []string{"id"}}, {family: "backups", op: "download", method: "GET", path: "/backups/:id/download", args: []string{"id"}}, {family: "backups", op: "restore", method: "POST", path: "/backups/:id/restore", args: []string{"id"}}, - {family: "backups", op: "jobs", method: "GET", path: "/backups/jobs", args: nil}, - {family: "backups", op: "jobs-get", method: "GET", path: "/backups/jobs/:id", args: []string{"id"}}, {family: "certificates", op: "list", method: "GET", path: "/certificates", args: nil}, {family: "certificates", op: "create", method: "POST", path: "/certificates", args: nil}, + {family: "certificates", op: "renew-all", method: "POST", path: "/certificates/renew", args: nil}, {family: "certificates", op: "delete", method: "DELETE", path: "/certificates/:domain", args: []string{"domain"}}, {family: "certificates", op: "get", method: "GET", path: "/certificates/:domain", args: []string{"domain"}}, {family: "certificates", op: "auto-renew", method: "PATCH", path: "/certificates/:domain/auto-renew", args: []string{"domain"}}, {family: "certificates", op: "renew", method: "POST", path: "/certificates/:domain/renew", args: []string{"domain"}}, - {family: "certificates", op: "renew-all", method: "POST", path: "/certificates/renew", args: nil}, {family: "cluster", op: "accept", method: "POST", path: "/cluster/accept", args: nil}, {family: "cluster", op: "deployments", method: "GET", path: "/cluster/deployments", args: nil}, {family: "cluster", op: "exchange", method: "POST", path: "/cluster/exchange", args: nil}, @@ -57,9 +59,10 @@ var generatedEndpoints = []endpoint{ {family: "cluster", op: "status", method: "GET", path: "/cluster/status", args: nil}, {family: "compose", op: "update", method: "POST", path: "/compose/update", args: nil}, {family: "config", op: "list", method: "GET", path: "/config", args: nil}, - {family: "config", op: "*key", method: "GET", path: "/config/*key", args: nil}, - {family: "config", op: "*key-update", method: "PUT", path: "/config/*key", args: nil}, + {family: "config", op: "get", method: "GET", path: "/config/*key", args: []string{"key"}}, + {family: "config", op: "update", method: "PUT", path: "/config/*key", args: []string{"key"}}, {family: "containers", op: "list", method: "GET", path: "/containers", args: nil}, + {family: "containers", op: "stats", method: "GET", path: "/containers/stats", args: nil}, {family: "containers", op: "delete", method: "DELETE", path: "/containers/:id", args: []string{"id"}}, {family: "containers", op: "exec", method: "GET", path: "/containers/:id/exec", args: []string{"id"}}, {family: "containers", op: "exec-create", method: "POST", path: "/containers/:id/exec", args: []string{"id"}}, @@ -70,7 +73,6 @@ var generatedEndpoints = []endpoint{ {family: "containers", op: "start", method: "POST", path: "/containers/:id/start", args: []string{"id"}}, {family: "containers", op: "stats-get", method: "GET", path: "/containers/:id/stats", args: []string{"id"}}, {family: "containers", op: "stop", method: "POST", path: "/containers/:id/stop", args: []string{"id"}}, - {family: "containers", op: "stats", method: "GET", path: "/containers/stats", args: nil}, {family: "credentials", op: "list", method: "GET", path: "/credentials", args: nil}, {family: "credentials", op: "create", method: "POST", path: "/credentials", args: nil}, {family: "credentials", op: "delete", method: "DELETE", path: "/credentials/:id", args: []string{"id"}}, @@ -120,20 +122,20 @@ var generatedEndpoints = []endpoint{ {family: "deployments", op: "env-update", method: "PUT", path: "/deployments/:name/env", args: []string{"name"}}, {family: "deployments", op: "files", method: "GET", path: "/deployments/:name/files", args: []string{"name"}}, {family: "deployments", op: "files-info", method: "GET", path: "/deployments/:name/files-info", args: []string{"name"}}, - {family: "deployments", op: "files-*path-delete", method: "DELETE", path: "/deployments/:name/files/*path", args: []string{"name"}}, - {family: "deployments", op: "files-*path", method: "GET", path: "/deployments/:name/files/*path", args: []string{"name"}}, - {family: "deployments", op: "files-*path-create", method: "POST", path: "/deployments/:name/files/*path", args: []string{"name"}}, + {family: "deployments", op: "files-delete", method: "DELETE", path: "/deployments/:name/files/*path", args: []string{"name", "path"}}, + {family: "deployments", op: "files-get", method: "GET", path: "/deployments/:name/files/*path", args: []string{"name", "path"}}, + {family: "deployments", op: "files-create", method: "POST", path: "/deployments/:name/files/*path", args: []string{"name", "path"}}, {family: "deployments", op: "images", method: "GET", path: "/deployments/:name/images", args: []string{"name"}}, {family: "deployments", op: "images-cleanup", method: "POST", path: "/deployments/:name/images/cleanup", args: []string{"name"}}, - {family: "deployments", op: "jobs", method: "GET", path: "/deployments/:name/jobs/:jobId", args: []string{"name", "jobId"}}, {family: "deployments", op: "jobs-active", method: "GET", path: "/deployments/:name/jobs/active", args: []string{"name"}}, + {family: "deployments", op: "jobs", method: "GET", path: "/deployments/:name/jobs/:jobId", args: []string{"name", "jobId"}}, {family: "deployments", op: "log-sources", method: "GET", path: "/deployments/:name/log-sources", args: []string{"name"}}, {family: "deployments", op: "log-sources-update", method: "PUT", path: "/deployments/:name/log-sources", args: []string{"name"}}, {family: "deployments", op: "logs-delete", method: "DELETE", path: "/deployments/:name/logs", args: []string{"name"}}, {family: "deployments", op: "logs", method: "GET", path: "/deployments/:name/logs", args: []string{"name"}}, {family: "deployments", op: "metadata", method: "PUT", path: "/deployments/:name/metadata", args: []string{"name"}}, - {family: "deployments", op: "mkdir-*path", method: "POST", path: "/deployments/:name/mkdir/*path", args: []string{"name"}}, - {family: "deployments", op: "permissions-*path", method: "PUT", path: "/deployments/:name/permissions/*path", args: []string{"name"}}, + {family: "deployments", op: "mkdir", method: "POST", path: "/deployments/:name/mkdir/*path", args: []string{"name", "path"}}, + {family: "deployments", op: "permissions", method: "PUT", path: "/deployments/:name/permissions/*path", args: []string{"name", "path"}}, {family: "deployments", op: "protected-mode", method: "PUT", path: "/deployments/:name/protected-mode", args: []string{"name"}}, {family: "deployments", op: "pull", method: "POST", path: "/deployments/:name/pull", args: []string{"name"}}, {family: "deployments", op: "rebuild", method: "POST", path: "/deployments/:name/rebuild", args: []string{"name"}}, @@ -154,23 +156,32 @@ var generatedEndpoints = []endpoint{ {family: "deployments", op: "start", method: "POST", path: "/deployments/:name/start", args: []string{"name"}}, {family: "deployments", op: "stats", method: "GET", path: "/deployments/:name/stats", args: []string{"name"}}, {family: "deployments", op: "stop", method: "POST", path: "/deployments/:name/stop", args: []string{"name"}}, - {family: "deployments", op: "touch-*path", method: "POST", path: "/deployments/:name/touch/*path", args: []string{"name"}}, + {family: "deployments", op: "touch", method: "POST", path: "/deployments/:name/touch/*path", args: []string{"name", "path"}}, {family: "deployments", op: "traffic", method: "GET", path: "/deployments/:name/traffic", args: []string{"name"}}, {family: "deployments", op: "users", method: "GET", path: "/deployments/:name/users", args: []string{"name"}}, + {family: "dns", op: "powerdns-disable", method: "POST", path: "/dns/powerdns/disable", args: nil}, + {family: "dns", op: "powerdns-enable", method: "POST", path: "/dns/powerdns/enable", args: nil}, + {family: "dns", op: "powerdns-restart", method: "POST", path: "/dns/powerdns/restart", args: nil}, + {family: "dns", op: "powerdns-status", method: "GET", path: "/dns/powerdns/status", args: nil}, + {family: "dns", op: "powerdns-zones", method: "GET", path: "/dns/powerdns/zones", args: nil}, + {family: "dns", op: "powerdns-zones-create", method: "POST", path: "/dns/powerdns/zones", args: nil}, + {family: "dns", op: "powerdns-zones-delete", method: "DELETE", path: "/dns/powerdns/zones/:zoneId", args: []string{"zoneId"}}, + {family: "dns", op: "powerdns-zones-get", method: "GET", path: "/dns/powerdns/zones/:zoneId", args: []string{"zoneId"}}, + {family: "dns", op: "powerdns-zones-update", method: "PATCH", path: "/dns/powerdns/zones/:zoneId", args: []string{"zoneId"}}, {family: "dns", op: "providers", method: "GET", path: "/dns/providers", args: nil}, {family: "health", op: "list", method: "GET", path: "/health", args: nil}, {family: "images", op: "list", method: "GET", path: "/images", args: nil}, - {family: "images", op: "delete", method: "DELETE", path: "/images/:id", args: []string{"id"}}, {family: "images", op: "cleanup", method: "POST", path: "/images/cleanup", args: nil}, {family: "images", op: "pull", method: "POST", path: "/images/pull", args: nil}, + {family: "images", op: "delete", method: "DELETE", path: "/images/:id", args: []string{"id"}}, {family: "infrastructure", op: "list", method: "GET", path: "/infrastructure", args: nil}, + {family: "infrastructure", op: "migrate", method: "POST", path: "/infrastructure/migrate/:name", args: []string{"name"}}, + {family: "infrastructure", op: "stats", method: "GET", path: "/infrastructure/stats", args: nil}, {family: "infrastructure", op: "get", method: "GET", path: "/infrastructure/:name", args: []string{"name"}}, {family: "infrastructure", op: "logs", method: "GET", path: "/infrastructure/:name/logs", args: []string{"name"}}, {family: "infrastructure", op: "restart", method: "POST", path: "/infrastructure/:name/restart", args: []string{"name"}}, {family: "infrastructure", op: "start", method: "POST", path: "/infrastructure/:name/start", args: []string{"name"}}, {family: "infrastructure", op: "stop", method: "POST", path: "/infrastructure/:name/stop", args: []string{"name"}}, - {family: "infrastructure", op: "migrate", method: "POST", path: "/infrastructure/migrate/:name", args: []string{"name"}}, - {family: "infrastructure", op: "stats", method: "GET", path: "/infrastructure/stats", args: nil}, {family: "networks", op: "list", method: "GET", path: "/networks", args: nil}, {family: "networks", op: "create", method: "POST", path: "/networks", args: nil}, {family: "networks", op: "delete", method: "DELETE", path: "/networks/:name", args: []string{"name"}}, @@ -179,6 +190,7 @@ var generatedEndpoints = []endpoint{ {family: "notifications", op: "targets", method: "GET", path: "/notifications/targets", args: nil}, {family: "notifications", op: "targets-update", method: "PUT", path: "/notifications/targets", args: nil}, {family: "notifications", op: "test", method: "POST", path: "/notifications/test", args: nil}, + {family: "object-stores", op: "provision-managed", method: "POST", path: "/object-stores/provision-managed", args: nil}, {family: "object-stores", op: "attach", method: "POST", path: "/object-stores/:name/attach", args: []string{"name"}}, {family: "object-stores", op: "buckets", method: "GET", path: "/object-stores/:name/buckets", args: []string{"name"}}, {family: "object-stores", op: "buckets-create", method: "POST", path: "/object-stores/:name/buckets", args: []string{"name"}}, @@ -188,7 +200,7 @@ var generatedEndpoints = []endpoint{ {family: "object-stores", op: "objects-create", method: "POST", path: "/object-stores/:name/objects", args: []string{"name"}}, {family: "object-stores", op: "objects-download", method: "GET", path: "/object-stores/:name/objects/download", args: []string{"name"}}, {family: "object-stores", op: "replicate", method: "POST", path: "/object-stores/:name/replicate", args: []string{"name"}}, - {family: "object-stores", op: "provision-managed", method: "POST", path: "/object-stores/provision-managed", args: nil}, + {family: "openapi.json", op: "list", method: "GET", path: "/openapi.json", args: nil}, {family: "plans", op: "list", method: "GET", path: "/plans", args: nil}, {family: "plans", op: "delete", method: "DELETE", path: "/plans/:id", args: []string{"id"}}, {family: "plans", op: "get", method: "GET", path: "/plans/:id", args: []string{"id"}}, @@ -198,11 +210,11 @@ var generatedEndpoints = []endpoint{ {family: "plugins", op: "deployments", method: "POST", path: "/plugins/:name/deployments", args: []string{"name"}}, {family: "ports", op: "list", method: "GET", path: "/ports", args: nil}, {family: "ports", op: "kill", method: "POST", path: "/ports/:pid/kill", args: []string{"pid"}}, - {family: "proxy", op: "delete", method: "DELETE", path: "/proxy/:name", args: []string{"name"}}, {family: "proxy", op: "setup", method: "POST", path: "/proxy/setup/:name", args: []string{"name"}}, {family: "proxy", op: "status", method: "GET", path: "/proxy/status/:name", args: []string{"name"}}, {family: "proxy", op: "sync", method: "POST", path: "/proxy/sync", args: nil}, {family: "proxy", op: "vhosts", method: "GET", path: "/proxy/vhosts", args: nil}, + {family: "proxy", op: "delete", method: "DELETE", path: "/proxy/:name", args: []string{"name"}}, {family: "registries", op: "list", method: "GET", path: "/registries", args: nil}, {family: "registries", op: "create", method: "POST", path: "/registries", args: nil}, {family: "registries", op: "delete", method: "DELETE", path: "/registries/:slug", args: []string{"slug"}}, @@ -258,31 +270,36 @@ var generatedEndpoints = []endpoint{ {family: "subdomain", op: "generate", method: "GET", path: "/subdomain/generate", args: nil}, {family: "system", op: "files", method: "GET", path: "/system/files", args: nil}, {family: "system", op: "files-info", method: "GET", path: "/system/files-info", args: nil}, - {family: "system", op: "files-*path-delete", method: "DELETE", path: "/system/files/*path", args: nil}, - {family: "system", op: "files-*path", method: "GET", path: "/system/files/*path", args: nil}, - {family: "system", op: "files-*path-create", method: "POST", path: "/system/files/*path", args: nil}, + {family: "system", op: "files-delete", method: "DELETE", path: "/system/files/*path", args: []string{"path"}}, + {family: "system", op: "files-get", method: "GET", path: "/system/files/*path", args: []string{"path"}}, + {family: "system", op: "files-create", method: "POST", path: "/system/files/*path", args: []string{"path"}}, {family: "system", op: "logs-delete", method: "DELETE", path: "/system/logs", args: nil}, {family: "system", op: "logs", method: "GET", path: "/system/logs", args: nil}, {family: "system", op: "logs-sources", method: "GET", path: "/system/logs/sources", args: nil}, - {family: "system", op: "mkdir-*path", method: "POST", path: "/system/mkdir/*path", args: nil}, - {family: "system", op: "permissions-*path", method: "PUT", path: "/system/permissions/*path", args: nil}, + {family: "system", op: "mkdir", method: "POST", path: "/system/mkdir/*path", args: []string{"path"}}, + {family: "system", op: "permissions", method: "PUT", path: "/system/permissions/*path", args: []string{"path"}}, {family: "system", op: "services", method: "GET", path: "/system/services", args: nil}, {family: "system", op: "services-restart", method: "POST", path: "/system/services/:name/restart", args: []string{"name"}}, {family: "system", op: "services-start", method: "POST", path: "/system/services/:name/start", args: []string{"name"}}, {family: "system", op: "services-stop", method: "POST", path: "/system/services/:name/stop", args: []string{"name"}}, {family: "system", op: "terminal", method: "GET", path: "/system/terminal", args: nil}, - {family: "system", op: "touch-*path", method: "POST", path: "/system/touch/*path", args: nil}, + {family: "system", op: "touch", method: "POST", path: "/system/touch/*path", args: []string{"path"}}, {family: "templates", op: "list", method: "GET", path: "/templates", args: nil}, - {family: "templates", op: "compose", method: "GET", path: "/templates/:id/compose", args: []string{"id"}}, - {family: "templates", op: "generate", method: "POST", path: "/templates/:id/generate", args: []string{"id"}}, {family: "templates", op: "categories", method: "GET", path: "/templates/categories", args: nil}, {family: "templates", op: "infra-compose", method: "GET", path: "/templates/infra/:name/compose", args: []string{"name"}}, {family: "templates", op: "infra-generate", method: "POST", path: "/templates/infra/:name/generate", args: []string{"name"}}, {family: "templates", op: "refresh", method: "POST", path: "/templates/refresh", args: nil}, + {family: "templates", op: "compose", method: "GET", path: "/templates/:id/compose", args: []string{"id"}}, + {family: "templates", op: "generate", method: "POST", path: "/templates/:id/generate", args: []string{"id"}}, {family: "traffic", op: "cleanup", method: "POST", path: "/traffic/cleanup", args: nil}, {family: "traffic", op: "logs", method: "GET", path: "/traffic/logs", args: nil}, {family: "traffic", op: "stats", method: "GET", path: "/traffic/stats", args: nil}, {family: "traffic", op: "unknown-domains", method: "GET", path: "/traffic/unknown-domains", args: nil}, + {family: "users", op: "list", method: "GET", path: "/users", args: nil}, + {family: "users", op: "create", method: "POST", path: "/users", args: nil}, + {family: "users", op: "me", method: "GET", path: "/users/me", args: nil}, + {family: "users", op: "me-update", method: "PUT", path: "/users/me", args: nil}, + {family: "users", op: "me-password", method: "PUT", path: "/users/me/password", args: nil}, {family: "users", op: "delete", method: "DELETE", path: "/users/:id", args: []string{"id"}}, {family: "users", op: "get", method: "GET", path: "/users/:id", args: []string{"id"}}, {family: "users", op: "update", method: "PUT", path: "/users/:id", args: []string{"id"}}, @@ -290,11 +307,8 @@ var generatedEndpoints = []endpoint{ {family: "users", op: "deployments-create", method: "POST", path: "/users/:id/deployments", args: []string{"id"}}, {family: "users", op: "deployments-delete", method: "DELETE", path: "/users/:id/deployments/:name", args: []string{"id", "name"}}, {family: "users", op: "deployments-update", method: "PUT", path: "/users/:id/deployments/:name", args: []string{"id", "name"}}, - {family: "users", op: "me", method: "GET", path: "/users/me", args: nil}, - {family: "users", op: "me-update", method: "PUT", path: "/users/me", args: nil}, - {family: "users", op: "me-password", method: "PUT", path: "/users/me/password", args: nil}, {family: "volumes", op: "list", method: "GET", path: "/volumes", args: nil}, {family: "volumes", op: "create", method: "POST", path: "/volumes", args: nil}, - {family: "volumes", op: "delete", method: "DELETE", path: "/volumes/:name", args: []string{"name"}}, {family: "volumes", op: "prune", method: "POST", path: "/volumes/prune", args: nil}, + {family: "volumes", op: "delete", method: "DELETE", path: "/volumes/:name", args: []string{"name"}}, } diff --git a/internal/command/endpoints_test.go b/internal/command/endpoints_test.go index 8c26120..f07e7e5 100644 --- a/internal/command/endpoints_test.go +++ b/internal/command/endpoints_test.go @@ -293,8 +293,35 @@ func TestEveryGeneratedCommandIsReachableAndUnique(t *testing.T) { if !ok || found.path != e.path { t.Errorf("%q does not dispatch back to %s", key, e.path) } - if strings.Count(e.path, ":") != len(e.args) { - t.Errorf("%s has %d path parameters but %d arguments", e.path, strings.Count(e.path, ":"), len(e.args)) + // A parameter is written :name, or *name when it holds the rest of the path. + params := strings.Count(e.path, ":") + strings.Count(e.path, "*") + if params != len(e.args) { + t.Errorf("%s has %d path parameters but %d arguments", e.path, params, len(e.args)) } } } + +// A wildcard holds the rest of the path, so its separators are structure and must survive. +func TestWildcardArgumentKeepsItsSeparators(t *testing.T) { + server, got := recordingServer(t, `{}`) + + code, _, stderr := runCLI(t, server, "deployments", "files-get", "shop", "src/app/main.go", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if got.path != "/api/deployments/shop/files/src/app/main.go" { + t.Fatalf("path = %s", got.path) + } +} + +func TestWildcardArgumentStillEscapesEachSegment(t *testing.T) { + server, got := recordingServer(t, `{}`) + + code, _, stderr := runCLI(t, server, "deployments", "files-get", "shop", "a b/c?d", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if !strings.Contains(got.rawURI, "a%20b/c%3Fd") { + t.Fatalf("the segments were not escaped: %s", got.rawURI) + } +} diff --git a/internal/command/schema.go b/internal/command/schema.go index 6ffe40b..afaeb25 100644 --- a/internal/command/schema.go +++ b/internal/command/schema.go @@ -14,12 +14,18 @@ import ( ) // checkFields refuses an unknown or missing field before anything is sent, since a 400 names -// neither the field the agent wanted nor the one it did not understand. -func checkFields(api *spec.Spec, op spec.Operation, sent fieldValues) error { +// neither the field the agent wanted nor the one it did not understand. It reads the body being +// sent rather than the flags it came from, so --data is held to the same requirements as -f. +func checkFields(api *spec.Spec, op spec.Operation, payload any) error { fields := api.Fields(op) if len(fields) == 0 { return nil } + sent, ok := payload.(map[string]any) + if payload != nil && !ok { + // A body that is not an object, such as a bare array, has no fields to check. + return nil + } known := make(map[string]bool, len(fields)) names := make([]string, 0, len(fields)) @@ -53,7 +59,7 @@ func checkFields(api *spec.Spec, op spec.Operation, sent fieldValues) error { } } } - if len(missing) > 0 && len(sent) > 0 { + if len(missing) > 0 { return fmt.Errorf("missing required field %s", strings.Join(missing, ", ")) } return nil diff --git a/internal/command/schema_test.go b/internal/command/schema_test.go index 1ab6238..9538c2d 100644 --- a/internal/command/schema_test.go +++ b/internal/command/schema_test.go @@ -246,3 +246,33 @@ func TestNamesPrintAsLinesNotATable(t *testing.T) { t.Fatalf("expected one name per line, got:\n%q", stdout) } } + +// A required field is required however the body was given, so --data is held to it too. +func TestRequiredFieldIsCheckedWhateverCarriesTheBody(t *testing.T) { + isolateCache(t) + server, got := describingServer(t, `{"message":"created"}`) + + code, _, stderr := runCLI(t, server, "backups", "create", "--data", `{"description":"nightly"}`) + if code == 0 { + t.Fatal("a body missing a required field should fail") + } + if got.path != "" { + t.Fatal("nothing should have been sent") + } + if !strings.Contains(stderr, "deployment_name") { + t.Fatalf("the error should name the missing field, got %s", stderr) + } +} + +func TestNoBodyAtAllStillReportsWhatIsRequired(t *testing.T) { + isolateCache(t) + server, _ := describingServer(t, `{"message":"created"}`) + + code, _, stderr := runCLI(t, server, "backups", "create") + if code == 0 { + t.Fatal("an endpoint with a required field should not accept an empty body") + } + if !strings.Contains(stderr, "deployment_name") { + t.Fatalf("the error should name the missing field, got %s", stderr) + } +} diff --git a/tools/gen_endpoints.py b/tools/gen_endpoints.py index 0570274..5195f6a 100644 --- a/tools/gen_endpoints.py +++ b/tools/gen_endpoints.py @@ -1,120 +1,115 @@ #!/usr/bin/env python3 -"""Regenerate internal/command/endpoints_gen.go from the agent's route table. +"""Regenerate internal/command/endpoints_gen.go from the agent's API description. - python3 tools/gen_endpoints.py ../agent > internal/command/endpoints_gen.go + python3 tools/gen_endpoints.py ../agent/internal/api/openapi.json > internal/command/endpoints_gen.go -The agent registers its routes in internal/api/server.go and publishes no machine-readable -spec, so the routes are read from that file. Adding an endpoint there and rerunning this is all -the CLI needs to reach it. +The agent describes itself, so the commands are named from that description rather than from a +second reading of the agent's source. Whatever the agent serves is what the CLI can reach. """ import collections import json -import re import sys -GROUP_PREFIX = { - "api": "", - "protected": "", - "setupGroup": "/setup", - "guarded": "/setup", - "usersGroup": "/users", - "apiKeysGroup": "/apikeys", - "dnsGroup": "/dns", - "clusterGroup": "/cluster", -} - -# Reached by the agent's own plugins and by nginx, never by an operator. -SKIP_PREFIXES = ("/internal", "/_internal", "/security/events/ingest", "/traffic/ingest") - -# Streaming endpoints: a websocket or a long-lived follow, which the table's request/response -# shape cannot carry. -SKIP_SUFFIXES = ("/stream", "/ws", "/terminal/interactive", "/exec/interactive") - WRITE_VERB = {"POST": "create", "PUT": "update", "PATCH": "update", "DELETE": "delete"} +# Reached by the agent's own components, not by an operator. +SKIP_PREFIXES = ("/api/internal", "/api/_internal", "/api/security/events/ingest", "/api/traffic/ingest") -def routes(agent_path): - src = open(agent_path + "/internal/api/server.go").read() - pattern = re.compile(r'\b(\w+)\.(GET|POST|PUT|DELETE|PATCH)\(\s*"([^"]+)"(.*?)\)\s*$', re.M) - for match in pattern.finditer(src): - group, method, path, rest = match.groups() - if group not in GROUP_PREFIX: - continue - full = GROUP_PREFIX[group] + path - if full.startswith(SKIP_PREFIXES) or full.endswith(SKIP_SUFFIXES): + +def endpoints(spec): + for path, methods in spec["paths"].items(): + if path.startswith(SKIP_PREFIXES): continue - perm = re.search(r"auth\.(Perm\w+)", rest) - yield {"method": method, "path": full, "perm": perm.group(1) if perm else ""} + for method, operation in methods.items(): + params = [p for p in operation.get("parameters", []) if p["in"] == "path"] + yield { + "method": method.upper(), + # The CLI holds paths as the router writes them, without the /api the client adds. + "path": path[len("/api"):], + "args": [p["name"] for p in params], + "rest": {p["name"] for p in params if p.get("x-rest-of-path")}, + } def op_name(method, segments): - literals = [s for s in segments if not s.startswith(":")] - params = [s for s in segments if s.startswith(":")] + literals = [s for s in segments if not s.startswith("{")] + params = [s for s in segments if s.startswith("{")] if not literals: if method == "GET": return "get" if params else "list" return WRITE_VERB[method] - name = "-".join(literals) - return name + return "-".join(literals) + + +def cli_path(endpoint): + """The path as the router writes it, so a segment holding the rest of the path stays marked.""" + out = [] + for segment in endpoint["path"].strip("/").split("/"): + if not segment.startswith("{"): + out.append(segment) + continue + name = segment.strip("{}") + out.append(("*" if name in endpoint["rest"] else ":") + name) + return "/" + "/".join(out) -def build(agent_path): +def build(spec): families = collections.defaultdict(list) - for route in routes(agent_path): - segments = route["path"].strip("/").split("/") - family, rest = segments[0], segments[1:] - families[family].append((route, rest)) + for endpoint in endpoints(spec): + segments = endpoint["path"].strip("/").split("/") + families[segments[0]].append((endpoint, segments[1:])) table = [] for family in sorted(families): - used = collections.Counter() - entries = [] - for route, rest in sorted(families[family], key=lambda r: (r[0]["path"], r[0]["method"])): - name = op_name(route["method"], rest) - entries.append([name, route, rest]) - # Several endpoints under one noun share a name: the collection and the single item, - # and the read and the write. The plainest one keeps the bare name and the rest say what - # they do, so "domains" lists them and "domains-delete" removes one. - for name, _, _ in entries: - used[name] += 1 - plainest = {} - for name, route, rest in entries: - arg_count = sum(1 for s in rest if s.startswith(":")) - if route["method"] == "GET" and arg_count < plainest.get(name, (99,))[0]: - plainest[name] = (arg_count, route["path"]) + entries = [[op_name(e["method"], rest), e, rest] + for e, rest in sorted(families[family], key=lambda r: (r[0]["path"], r[0]["method"]))] + + # Several endpoints under one noun share a name: the collection and the single item, and + # the read and the write. The plainest keeps the bare name and the rest say what they do. + used = collections.Counter(name for name, _, _ in entries) methods = collections.defaultdict(set) - for name, route, _ in entries: - methods[name].add(route["method"]) + for name, endpoint, _ in entries: + methods[name].add(endpoint["method"]) + plainest = {} + for name, endpoint, _ in entries: + count = len(endpoint["args"]) + if endpoint["method"] == "GET" and count < plainest.get(name, (99,))[0]: + plainest[name] = (count, endpoint["path"]) + + # Taken before any renaming below, which would otherwise change what is being counted. + fewest_args = {} + for name, endpoint, _ in entries: + count = len(endpoint["args"]) + fewest_args[name] = min(fewest_args.get(name, count), count) + for entry in entries: - name, route, rest = entry + name, endpoint, _ = entry if used[name] == 1: continue - arg_count = sum(1 for s in rest if s.startswith(":")) + count = len(endpoint["args"]) if len(methods[name]) == 1: # The same verb on the collection and on one item. Whichever is safer to type by - # mistake keeps the bare name: reading the collection, but writing to one item, - # so "renew DOMAIN" renews one and "renew-all" says what it does. - fewest = min(sum(1 for s in r.strip("/").split("/") if s.startswith(":")) - for n, rt, r in [(n, rt, rt["path"]) for n, rt, _ in entries if n == name]) - if route["method"] == "GET": - if arg_count > fewest: + # mistake keeps the bare name: reading the collection, but writing to one item, so + # "renew DOMAIN" renews one and "renew-all" says what it does. + fewest = fewest_args[name] + if endpoint["method"] == "GET": + if count > fewest: entry[0] = name + "-get" - elif arg_count == fewest: + elif count == fewest: entry[0] = name + "-all" continue - if name in plainest and plainest[name][1] == route["path"] and route["method"] == "GET": + if name in plainest and plainest[name][1] == endpoint["path"] and endpoint["method"] == "GET": continue - entry[0] = name + "-" + ("get" if route["method"] == "GET" else WRITE_VERB[route["method"]]) - for name, route, rest in entries: - args = [s.lstrip(":") for s in route["path"].strip("/").split("/") if s.startswith(":")] + entry[0] = name + "-" + ("get" if endpoint["method"] == "GET" else WRITE_VERB[endpoint["method"]]) + + for name, endpoint, _ in entries: table.append({ "family": family, "op": name, - "method": route["method"], - "path": route["path"], - "args": args, - "perm": route["perm"], + "method": endpoint["method"], + "path": cli_path(endpoint), + "args": endpoint["args"], }) return table @@ -122,22 +117,24 @@ def build(agent_path): def main(): args = [a for a in sys.argv[1:] if not a.startswith("--")] if len(args) != 1: - sys.exit("usage: gen_endpoints.py PATH_TO_AGENT_CHECKOUT [--json]") - table = build(args[0]) + sys.exit("usage: gen_endpoints.py PATH_TO_OPENAPI_JSON [--json]") + table = build(json.load(open(args[0]))) + if "--json" in sys.argv: print(json.dumps(table, indent=1)) return - out = [] - out.append("// Code generated by tools/gen_endpoints.py from the agent's route table. DO NOT EDIT.") - out.append("") - out.append("package command") - out.append("") - out.append("var generatedEndpoints = []endpoint{") - for e in table: - args = "nil" if not e["args"] else "[]string{" + ", ".join('"%s"' % a for a in e["args"]) + "}" + out = [ + "// Code generated by tools/gen_endpoints.py from the agent's API description. DO NOT EDIT.", + "", + "package command", + "", + "var generatedEndpoints = []endpoint{", + ] + for entry in table: + args = "nil" if not entry["args"] else "[]string{" + ", ".join('"%s"' % a for a in entry["args"]) + "}" out.append('\t{family: "%s", op: "%s", method: "%s", path: "%s", args: %s},' - % (e["family"], e["op"], e["method"], e["path"], args)) + % (entry["family"], entry["op"], entry["method"], entry["path"], args)) out.append("}") print("\n".join(out)) From a566cd45c5ed65b6643929edf50616c3fbf901fd Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 15 Aug 2026 10:56:39 +0100 Subject: [PATCH 08/10] fix: Print the same thing whichever name a command is reached by `containers list` printed a wall of JSON where `container list` printed a table, for the same data, because only the hand-shaped name had a renderer. Both names now reach the same command. An answer nothing has described is laid out from the answer itself: the field holding the rows becomes the table, its scalar fields become the columns, in the order they were written. So an endpoint the agent has not typed yet, or an agent too old to describe itself at all, reads the same as one that has. The API description was also being listed as a resource family of the API. --- internal/command/endpoints.go | 2 +- internal/command/endpoints_gen.go | 1 - internal/command/root.go | 5 ++ internal/command/schema.go | 101 ++++++++++++++++++++++++++---- internal/command/schema_test.go | 41 ++++++++++++ internal/command/shaped.go | 28 ++++++++- tools/gen_endpoints.py | 5 +- 7 files changed, 168 insertions(+), 15 deletions(-) diff --git a/internal/command/endpoints.go b/internal/command/endpoints.go index 2f13487..959c680 100644 --- a/internal/command/endpoints.go +++ b/internal/command/endpoints.go @@ -207,7 +207,7 @@ func runEndpoint(family string, args []string, stdout, stderr io.Writer) int { return client.Do(ctx, e.method, path, payload) }, render: func(w io.Writer, data []byte) error { - if described && renderAnswer(w, api, operation, data) { + if renderAnswer(w, api, operation, data) { return nil } printResponse(w, true, data, "") diff --git a/internal/command/endpoints_gen.go b/internal/command/endpoints_gen.go index ebcca0e..440a4a4 100644 --- a/internal/command/endpoints_gen.go +++ b/internal/command/endpoints_gen.go @@ -200,7 +200,6 @@ var generatedEndpoints = []endpoint{ {family: "object-stores", op: "objects-create", method: "POST", path: "/object-stores/:name/objects", args: []string{"name"}}, {family: "object-stores", op: "objects-download", method: "GET", path: "/object-stores/:name/objects/download", args: []string{"name"}}, {family: "object-stores", op: "replicate", method: "POST", path: "/object-stores/:name/replicate", args: []string{"name"}}, - {family: "openapi.json", op: "list", method: "GET", path: "/openapi.json", args: nil}, {family: "plans", op: "list", method: "GET", path: "/plans", args: nil}, {family: "plans", op: "delete", method: "DELETE", path: "/plans/:id", args: []string{"id"}}, {family: "plans", op: "get", method: "GET", path: "/plans/:id", args: []string{"id"}}, diff --git a/internal/command/root.go b/internal/command/root.go index 700fb05..3c442fb 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -209,6 +209,11 @@ func Run(args []string, stdout, stderr io.Writer) int { default: // Families the CLI does not shape by hand still reach the agent, through the // generated table, so a new endpoint there is reachable here without a wrapper. + if singular, aliased := shapedAlias[args[0]]; aliased && len(args) > 1 { + if shapedCommand(singular, args[1]) { + return runShaped(singular, args[1:], stdout, stderr) + } + } if knownFamily(args[0]) { return runEndpoint(args[0], args[1:], stdout, stderr) } diff --git a/internal/command/schema.go b/internal/command/schema.go index afaeb25..0363abb 100644 --- a/internal/command/schema.go +++ b/internal/command/schema.go @@ -1,6 +1,7 @@ package command import ( + "bytes" "encoding/json" "fmt" "io" @@ -145,16 +146,24 @@ func describeEndpoint(w io.Writer, api *spec.Spec, e endpoint, op spec.Operation // renderAnswer lays out a response by the shape the agent answers in, so a list of certificates // and a list of backups take the same path. func renderAnswer(w io.Writer, api *spec.Spec, op spec.Operation, data []byte) bool { - shape, ok := api.Shape(op) - if !ok { - return false - } - var body map[string]json.RawMessage if err := json.Unmarshal(data, &body); err != nil { return false } + shape, ok := spec.Shape{}, false + if api != nil { + shape, ok = api.Shape(op) + } + if !ok { + // An endpoint the agent has not typed yet, or an agent too old to describe itself. The + // answer still says which of its fields holds the rows. + shape, ok = inferShape(body) + if !ok { + return false + } + } + switch shape.Kind { case "list": return renderList(w, shape, body) @@ -173,6 +182,31 @@ func renderAnswer(w io.Writer, api *spec.Spec, op spec.Operation, data []byte) b return false } +// inferShape finds the rows in an answer that nothing described: the one field holding a list. +func inferShape(body map[string]json.RawMessage) (spec.Shape, bool) { + // The shared list shape carries its rows under items, and the name it used to answer under + // beside them, so there is no ambiguity to resolve. + if _, ok := body["items"]; ok { + return spec.Shape{Kind: "list", Key: "items"}, true + } + + found := "" + for name, raw := range body { + var rows []json.RawMessage + if err := json.Unmarshal(raw, &rows); err != nil { + continue + } + if found != "" { + return spec.Shape{}, false + } + found = name + } + if found == "" { + return spec.Shape{}, false + } + return spec.Shape{Kind: "list", Key: found}, true +} + func renderList(w io.Writer, shape spec.Shape, body map[string]json.RawMessage) bool { raw, ok := body[shape.Key] if !ok { @@ -202,12 +236,7 @@ func renderList(w io.Writer, shape spec.Shape, body map[string]json.RawMessage) columns := shape.Columns if len(columns) == 0 { - for name, value := range rows[0] { - if _, nested := value.(map[string]any); !nested { - columns = append(columns, name) - } - } - sort.Strings(columns) + columns = inferColumns(raw, rows[0]) } if len(columns) == 1 { for _, row := range rows { @@ -233,6 +262,56 @@ func renderList(w io.Writer, shape spec.Shape, body map[string]json.RawMessage) return true } +// inferColumns takes the scalar fields of a row, in the order the answer wrote them, since a map +// has no order and alphabetical puts the identifier in the middle. +func inferColumns(raw json.RawMessage, first map[string]any) []string { + scalar := map[string]bool{} + for name, value := range first { + switch value.(type) { + case map[string]any, []any: + default: + scalar[name] = true + } + } + + var ordered []string + decoder := json.NewDecoder(bytes.NewReader(raw)) + depth := 0 + for { + token, err := decoder.Token() + if err != nil { + break + } + switch typed := token.(type) { + case json.Delim: + if typed == '{' || typed == '[' { + depth++ + } else { + depth-- + } + if depth < 2 && len(ordered) > 0 { + // Past the first row, so the order is settled. + return capColumns(ordered) + } + case string: + if depth == 2 && scalar[typed] { + ordered = append(ordered, typed) + scalar[typed] = false + } + } + } + return capColumns(ordered) +} + +func capColumns(columns []string) []string { + if len(columns) > maxInferredColumns { + return columns[:maxInferredColumns] + } + return columns +} + +const maxInferredColumns = 6 + func renderItem(w io.Writer, shape spec.Shape, body map[string]json.RawMessage) bool { raw, ok := body[shape.Key] if !ok { diff --git a/internal/command/schema_test.go b/internal/command/schema_test.go index 9538c2d..efd2012 100644 --- a/internal/command/schema_test.go +++ b/internal/command/schema_test.go @@ -276,3 +276,44 @@ func TestNoBodyAtAllStillReportsWhatIsRequired(t *testing.T) { t.Fatalf("the error should name the missing field, got %s", stderr) } } + +// An agent that has not typed an endpoint, or is too old to describe itself at all, should still +// get a table rather than a wall of JSON. +func TestUndescribedListStillPrintsAsATable(t *testing.T) { + isolateCache(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/openapi.json") { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"backups":[{"id":"b-1","deployment_name":"shop","status":"complete"}]}`)) + })) + defer server.Close() + + code, stdout, stderr := runCLI(t, server, "backups", "list") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if !strings.Contains(stdout, "ID") || !strings.Contains(stdout, "b-1") { + t.Fatalf("expected a table, got:\n%s", stdout) + } +} + +// Columns follow the order the answer wrote them in, since alphabetical buries the identifier. +func TestInferredColumnsKeepTheAnswersOrder(t *testing.T) { + isolateCache(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/openapi.json") { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"backups":[{"id":"b-1","zone":"eu","alpha":"a"}]}`)) + })) + defer server.Close() + + _, stdout, _ := runCLI(t, server, "backups", "list") + heading := strings.SplitN(stdout, "\n", 2)[0] + if strings.Index(heading, "ID") > strings.Index(heading, "ALPHA") { + t.Fatalf("the identifier should come first, got %q", heading) + } +} diff --git a/internal/command/shaped.go b/internal/command/shaped.go index a61feaf..471c81c 100644 --- a/internal/command/shaped.go +++ b/internal/command/shaped.go @@ -1,6 +1,9 @@ package command -import "strings" +import ( + "io" + "strings" +) // shapedCommands are the commands written by hand rather than taken from the route table, // because they render a table, take flags shaped for the task, or read a command after `--`. @@ -81,3 +84,26 @@ func invocation(e endpoint) string { } return strings.Join(parts, " ") } + +func shapedCommand(family, op string) bool { + for _, e := range shapedCommands { + if e.family == family && e.op == op { + return true + } + } + return false +} + +// runShaped dispatches to the hand-written command, so `containers list` and `container list` +// print the same thing rather than one table and one wall of JSON. +func runShaped(family string, args []string, stdout, stderr io.Writer) int { + switch family { + case "deployment": + return runDeployment(args, stdout, stderr) + case "image": + return runImage(args, stdout, stderr) + case "container": + return runContainer(args, stdout, stderr) + } + return 2 +} diff --git a/tools/gen_endpoints.py b/tools/gen_endpoints.py index 5195f6a..23935cb 100644 --- a/tools/gen_endpoints.py +++ b/tools/gen_endpoints.py @@ -16,10 +16,13 @@ # Reached by the agent's own components, not by an operator. SKIP_PREFIXES = ("/api/internal", "/api/_internal", "/api/security/events/ingest", "/api/traffic/ingest") +# The description of the API is not a resource of it. +SKIP_PATHS = ("/api/openapi.json",) + def endpoints(spec): for path, methods in spec["paths"].items(): - if path.startswith(SKIP_PREFIXES): + if path.startswith(SKIP_PREFIXES) or path in SKIP_PATHS: continue for method, operation in methods.items(): params = [p for p in operation.get("parameters", []) if p["in"] == "path"] From 2bf171552e80a832a0d906820f03689381e0d698 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 15 Aug 2026 11:02:57 +0100 Subject: [PATCH 09/10] fix: One list of resources, reachable by either spelling Help had two lists with two shapes: a Commands block naming three resources in the singular, and a separate line naming forty in the plural. Which list a resource appeared in, and which spelling it answered to, depended only on whether someone had hand-written its commands. There is now one list of resources with the incidental commands below it, and every resource answers to either spelling, so nobody has to remember whether the API said backup or backups. --- CHANGELOG.md | 2 +- README.md | 4 +- docs/reference/commands.md | 7 +-- internal/command/endpoints_test.go | 28 ++++++++++++ internal/command/root.go | 69 +++++++++++++++++++++--------- internal/command/shaped.go | 17 ++++++++ 6 files changed, 100 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74b6d3d..6ab2548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to the FlatRun CLI are documented in this file. ### Added - Every agent endpoint is now a command: `flatrun FAMILY OPERATION [ARGS]`, covering 294 endpoints across 42 families. The table is generated from the agent's routes, so catching up is a regeneration rather than 294 hand-written wrappers. -- `flatrun` lists the families, `flatrun FAMILY` lists its commands, and `--json` on either prints the same list with each command's method, path and arguments, for scripts and agents. One listing covers both the hand-shaped commands and the generated ones, and the singular families reach everything their plural counterparts do, so `deployment log-sources` works. +- `flatrun` lists the resources, `flatrun RESOURCE` lists its commands, with singular and plural both accepted, and `--json` on either prints the same list with each command's method, path and arguments, for scripts and agents. One listing covers both the hand-shaped commands and the generated ones, and the singular families reach everything their plural counterparts do, so `deployment log-sources` works. - Request bodies from repeatable `-f name=value`, or `--data JSON` / `--data @file.json`. A field value that reads as JSON is sent as JSON, so `-f enabled=true` sends a boolean. Query parameters with repeatable `-q name=value`. - Commands read the agent's own description of its API where the agent serves one, so a mistyped field or query parameter fails before the request with the name it was probably meant to be, `COMMAND --help` lists the fields an endpoint takes and the permission it needs, and answers print as tables laid out from the types the agent returns. An agent that does not describe itself behaves as before. diff --git a/README.md b/README.md index 8e45c88..50c28d3 100644 --- a/README.md +++ b/README.md @@ -102,9 +102,9 @@ The commands above are shaped by hand because they print tables worth reading. E endpoint is `flatrun FAMILY OPERATION [ARGS]`, from a table generated out of the agent's routes. ```bash -flatrun # the families +flatrun # the resources flatrun backups # what backups can do -flatrun backups list +flatrun backup list # singular and plural both work flatrun certificates renew shop.example.com flatrun deployment logs my-api -q service=web -q tail=200 ``` diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 2f7cb71..f1ff16d 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -136,8 +136,9 @@ The families above are shaped by hand. Every other agent endpoint is `flatrun FAMILY OPERATION [ARGS]`, from a table generated out of the agent's routes. ```bash -flatrun # the families +flatrun # the resources flatrun backups # what backups can do +flatrun backup list # singular and plural both work flatrun backups list flatrun backups restore BACKUP_ID flatrun certificates renew shop.example.com @@ -169,8 +170,8 @@ flatrun deployment logs my-api -q service=web -q tail=200 ## Listing what exists ```bash -flatrun # the families -flatrun backups # one family +flatrun # the resources +flatrun backups # one resource flatrun --json # every command as JSON flatrun backups --json # one family as JSON ``` diff --git a/internal/command/endpoints_test.go b/internal/command/endpoints_test.go index f07e7e5..1bc9f0b 100644 --- a/internal/command/endpoints_test.go +++ b/internal/command/endpoints_test.go @@ -325,3 +325,31 @@ func TestWildcardArgumentStillEscapesEachSegment(t *testing.T) { t.Fatalf("the segments were not escaped: %s", got.rawURI) } } + +// Nobody should have to remember whether the API called it backup or backups. +func TestEitherSpellingReachesTheSameResource(t *testing.T) { + for _, spelling := range []string{"backups", "backup", "certificates", "certificate"} { + server, got := recordingServer(t, `{"items":[],"total":0}`) + code, _, stderr := runCLI(t, server, spelling, "list", "--json") + if code != 0 { + t.Fatalf("%s: code=%d stderr=%s", spelling, code, stderr) + } + if got.path == "" { + t.Errorf("%s reached nothing", spelling) + } + } +} + +func TestUnknownResourceIsStillUnknown(t *testing.T) { + server, got := recordingServer(t, `{}`) + code, _, stderr := runCLI(t, server, "bakcups", "list") + if code == 0 { + t.Fatal("a misspelt resource should not be accepted") + } + if got.path != "" { + t.Fatal("nothing should have been sent") + } + if !strings.Contains(stderr, "Unknown command") { + t.Fatalf("stderr = %s", stderr) + } +} diff --git a/internal/command/root.go b/internal/command/root.go index 3c442fb..ac75577 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -209,17 +209,21 @@ func Run(args []string, stdout, stderr io.Writer) int { default: // Families the CLI does not shape by hand still reach the agent, through the // generated table, so a new endpoint there is reachable here without a wrapper. - if singular, aliased := shapedAlias[args[0]]; aliased && len(args) > 1 { - if shapedCommand(singular, args[1]) { + family, known := resolveFamily(args[0]) + if !known { + _, _ = fmt.Fprintf(stderr, "Unknown command: %s\n\n", args[0]) + usage(stderr) + return 2 + } + if singular, aliased := shapedAlias[family]; aliased { + if len(args) > 1 && shapedCommand(singular, args[1]) { + return runShaped(singular, args[1:], stdout, stderr) + } + if len(args) == 1 { return runShaped(singular, args[1:], stdout, stderr) } } - if knownFamily(args[0]) { - return runEndpoint(args[0], args[1:], stdout, stderr) - } - _, _ = fmt.Fprintf(stderr, "Unknown command: %s\n\n", args[0]) - usage(stderr) - return 2 + return runEndpoint(family, args[1:], stdout, stderr) } } @@ -227,22 +231,45 @@ func usage(w io.Writer) { _, _ = fmt.Fprintln(w, "FlatRun CLI") _, _ = fmt.Fprintln(w) _, _ = fmt.Fprintln(w, "Usage:") - _, _ = fmt.Fprintln(w, " flatrun [options]") + _, _ = fmt.Fprintln(w, " flatrun RESOURCE OPERATION [ARGS] [options]") _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, "Commands:") - _, _ = fmt.Fprintln(w, " configure set Save a local profile") - _, _ = fmt.Fprintln(w, " configure list List local profiles") - _, _ = fmt.Fprintln(w, " health Check FlatRun API health") - _, _ = fmt.Fprintln(w, " deployment Manage deployments and their services/images/containers") - _, _ = fmt.Fprintln(w, " image Manage Docker images") - _, _ = fmt.Fprintln(w, " container Manage containers") - _, _ = fmt.Fprintln(w, " api Call any FlatRun API endpoint") - _, _ = fmt.Fprintln(w, " version Print CLI version") + _, _ = fmt.Fprintln(w, "Resources:") + for _, line := range wrapNames(families(), 88) { + _, _ = fmt.Fprintln(w, " "+line) + } + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, "Run `flatrun RESOURCE` for its operations. Singular and plural both work.") _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, "Resource families:") - _, _ = fmt.Fprintln(w, " "+strings.Join(families(), ", ")) + _, _ = fmt.Fprintln(w, "Other commands:") + _, _ = fmt.Fprintln(w, " configure Save and switch between local profiles") + _, _ = fmt.Fprintln(w, " health Check that the agent is reachable") + _, _ = fmt.Fprintln(w, " api Call any endpoint directly") + _, _ = fmt.Fprintln(w, " version Print CLI version") _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, "Run `flatrun ` for its commands, or add --json for all of them.") + _, _ = fmt.Fprintln(w, "Add --json to any command for the raw answer, or to a listing for every command.") +} + +func wrapNames(names []string, width int) []string { + var lines []string + current := "" + for i, name := range names { + if i < len(names)-1 { + name += "," + } + if current != "" && len(current)+1+len(name) > width { + lines = append(lines, current) + current = "" + } + if current == "" { + current = name + continue + } + current += " " + name + } + if current != "" { + lines = append(lines, current) + } + return lines } func globalFlagSet(name string, opts *globalOptions, output, debugOut io.Writer) *flag.FlagSet { diff --git a/internal/command/shaped.go b/internal/command/shaped.go index 471c81c..c60bd3d 100644 --- a/internal/command/shaped.go +++ b/internal/command/shaped.go @@ -107,3 +107,20 @@ func runShaped(family string, args []string, stdout, stderr io.Writer) int { } return 2 } + +// resolveFamily takes whichever way a resource was typed and answers with the one name the CLI +// holds it under. Nobody should have to remember whether the API said backup or backups. +func resolveFamily(typed string) (string, bool) { + if knownFamily(typed) { + return typed, true + } + if singular, ok := shapedAlias[typed]; ok { + return singular, true + } + for _, candidate := range []string{typed + "s", typed + "es", strings.TrimSuffix(typed, "s")} { + if candidate != typed && knownFamily(candidate) { + return candidate, true + } + } + return "", false +} From ab504a144197e712e6151b23ff890f29d37a4587 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 15 Aug 2026 11:09:23 +0100 Subject: [PATCH 10/10] docs: Say how output is laid out --- README.md | 6 ++++++ docs/reference/commands.md | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/README.md b/README.md index 50c28d3..d8c419f 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,12 @@ flatrun settings update --data @settings.json A field value that reads as JSON is sent as JSON: `-f enabled=true` sends a boolean, `-f retention=7` sends a number. +### Output + +If the agent describes the answer, that decides the layout. Otherwise an array of objects prints as +a table, an array of scalars one per line, an empty one as `None`, and anything else as raw JSON. +`--json` overrides all of it. + ### Driving it from a script or an agent `--json` on any listing prints every command with its method, path and arguments: diff --git a/docs/reference/commands.md b/docs/reference/commands.md index f1ff16d..5466d98 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -167,6 +167,21 @@ sends a boolean and `-f ports=[8080]` sends an array. The two body forms cannot flatrun deployment logs my-api -q service=web -q tail=200 ``` +## How output is laid out + +If the agent describes the answer, that description decides the layout. If it does not, the client +maps the JSON itself: + +| What comes back | What prints | +|---|---| +| Array of objects | A table, columns taken from the first row | +| Array of scalars | One per line | +| A single column | One per line, no heading | +| Empty array | `None` | +| No array, or more than one | The raw JSON | + +`--json` overrides all of it and prints the answer untouched. + ## Listing what exists ```bash