From 5b5f35cf6d8b4efca3f03a3d42225969025772a5 Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 11 Aug 2026 01:00:47 +0100 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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 }