diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c4741b..928eb99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to the FlatRun CLI are documented in this file. +## [0.4.0] - 2026-08-23 + +### Added + +- Per-operation help with request fields, types, accepted values, permissions, query parameters, and plan support +- Request body skeleton generation for operations described by the connected agent +- Plan mode for every operation that declares support in the agent API +- Agent and CLI version reporting when a server profile is saved +- Profile commands with explicit add, select, list, current, and remove operations +- Login through username/password or API key without placing credentials in shell history +- Terminal-aware colors, responsive tables, semantic statuses, and shell completion + +### Fixed + +- Image updates change the referenced environment variable instead of replacing a compose expression +- Generated API flags remain available for commands that also have a task-specific CLI form + ## [0.3.1] - 2026-08-21 ### Added diff --git a/README.md b/README.md index 9d49dcc..0ca1c64 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,34 @@ export FLATRUN_TOKEN=fr_xxx Or configure a local profile: ```bash -flatrun configure set --url https://panel.example.com --token fr_xxx -flatrun configure list +flatrun profile add production --url https://panel.example.com --token fr_xxx +flatrun profile use production +flatrun profile list ``` +Log in through the agent without placing a password in shell history: + +```bash +printf '%s\n' "$FLATRUN_PASSWORD" | + flatrun auth login --profile production --username operator --password-stdin +``` + +`configure` remains available as a compatibility alias for profile management. Config is stored at `~/.flatrun/config.json` by default. Use `FLATRUN_CONFIG` to override the path. +## Terminal output + +Human-readable output uses terminal-aware colors and tables. Colors are disabled automatically when output is piped or redirected. Set `NO_COLOR=1` to disable them explicitly. `--json` always returns unstyled machine-readable output. + +Generate completion for a supported shell: + +```bash +flatrun completion bash +flatrun completion zsh +flatrun completion fish +flatrun completion powershell +``` + ## Commands Deploy an existing app from CI by pulling images and then applying a runtime operation: @@ -174,4 +196,21 @@ Check API connectivity: flatrun health ``` +Learn any operation from the connected agent, then print a request body to fill in: + +```bash +flatrun deployments create --help +flatrun deployments create --generate-cli-skeleton +``` + +Operations that declare plan support accept `--plan`. The command returns the changes without +applying them: + +```bash +flatrun deployments delete staging --plan \ + -q delete_ssl=true \ + -q delete_database=true \ + -q delete_vhost=true +``` + See [docs](docs/README.md) for guides and command reference. diff --git a/VERSION b/VERSION index 9e11b32..1d0ba9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.1 +0.4.0 diff --git a/go.mod b/go.mod index db02b53..9733244 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,30 @@ module github.com/flatrun/cli -go 1.21 +go 1.25.0 -require gopkg.in/yaml.v3 v3.0.1 +require ( + charm.land/lipgloss/v2 v2.0.5 + github.com/spf13/cobra v1.10.2 + golang.org/x/term v0.45.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/go.sum b/go.sum index a62c313..a7bd2a4 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,52 @@ +charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= +charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/command/endpoints.go b/internal/command/endpoints.go index 959c680..661e952 100644 --- a/internal/command/endpoints.go +++ b/internal/command/endpoints.go @@ -14,6 +14,7 @@ import ( "time" "github.com/flatrun/cli/internal/flatrun" + "github.com/flatrun/cli/internal/presentation" "github.com/flatrun/cli/internal/spec" ) @@ -159,19 +160,23 @@ func runEndpoint(family string, args []string, stdout, stderr io.Writer) int { fields := fieldValues{} query := queryValues{} dataArg := "" + generateSkeleton := false + planOnly := false var api *spec.Spec var operation spec.Operation described := false cmd := clientCommand{ name: family + " " + e.op, - usage: "Usage: " + e.command() + " [-f name=value] [--data JSON] [-q name=value]", + usage: "Usage: " + e.command() + " [-f name=value] [--data JSON] [-q name=value] [--plan] [--generate-cli-skeleton]", 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") + fs.BoolVar(&planOnly, "plan", false, "Preview the operation without applying it") + fs.BoolVar(&generateSkeleton, "generate-cli-skeleton", false, "Print a request body template") }, run: func(ctx context.Context, client *flatrun.Client, positional []string) ([]byte, error) { path, err := e.resolvePath(positional) @@ -191,6 +196,18 @@ func runEndpoint(family string, args []string, stdout, stderr io.Writer) int { } } } + if generateSkeleton { + if !described { + return nil, fmt.Errorf("this agent does not describe the request body") + } + return json.MarshalIndent(api.Skeleton(operation), "", " ") + } + if planOnly { + if !described || !operation.PlanSupported { + return nil, fmt.Errorf("this operation does not support plan mode") + } + url.Values(query).Set("plan", "true") + } if len(query) > 0 { path += "?" + url.Values(query).Encode() @@ -207,6 +224,10 @@ 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 generateSkeleton { + printResponse(w, true, data, "") + return nil + } if renderAnswer(w, api, operation, data) { return nil } @@ -217,6 +238,15 @@ func runEndpoint(family string, args []string, stdout, stderr io.Writer) int { return runClientCommand(cmd, args[1:], stdout, stderr) } +func usesGenericEndpointFlags(args []string) bool { + for _, arg := range args { + if arg == "--data" || strings.HasPrefix(arg, "--data=") || arg == "-f" || strings.HasPrefix(arg, "-f=") || arg == "-q" || strings.HasPrefix(arg, "-q=") || arg == "--plan" || arg == "--generate-cli-skeleton" { + return true + } + } + return false +} + // 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 { @@ -339,17 +369,28 @@ func listEndpoints(stdout, stderr io.Writer, family string, asJSON bool) int { return 0 } + view := presentation.New(stdout) current := "" + rows := [][]string{} + flush := func() { + if current == "" { + return + } + _, _ = fmt.Fprintln(stdout, view.Heading(current)) + view.Table(stdout, []string{"OPERATION", "METHOD", "PATH"}, rows) + } for _, e := range list { if e.family != current { + flush() if current != "" { _, _ = fmt.Fprintln(stdout) } current = e.family - _, _ = fmt.Fprintln(stdout, e.family) + rows = nil } - _, _ = fmt.Fprintf(stdout, " %-38s %s %s\n", strings.TrimSpace(e.op+" "+argNames(e)+" "+e.flags), e.method, e.path) + rows = append(rows, []string{strings.TrimSpace(e.op + " " + argNames(e) + " " + e.flags), e.method, e.path}) } + flush() _, _ = 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_gen.go b/internal/command/endpoints_gen.go index 852a7ba..e4a32fd 100644 --- a/internal/command/endpoints_gen.go +++ b/internal/command/endpoints_gen.go @@ -18,6 +18,7 @@ var generatedEndpoints = []endpoint{ {family: "ai", op: "sessions-approve", method: "POST", path: "/ai/sessions/:id/approve", args: []string{"id"}}, {family: "ai", op: "sessions-messages", method: "POST", path: "/ai/sessions/:id/messages", args: []string{"id"}}, {family: "ai", op: "status", method: "GET", path: "/ai/status", args: nil}, + {family: "alerts", op: "target-options", method: "GET", path: "/alerts/target-options", args: nil}, {family: "apikeys", op: "list", method: "GET", path: "/apikeys", args: nil}, {family: "apikeys", op: "create", method: "POST", path: "/apikeys", args: nil}, {family: "apikeys", op: "delete", method: "DELETE", path: "/apikeys/:id", args: []string{"id"}}, @@ -42,6 +43,7 @@ var generatedEndpoints = []endpoint{ {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: "capacity", op: "list", method: "GET", path: "/capacity", args: nil}, {family: "certificates", op: "list", method: "GET", path: "/certificates", args: nil}, {family: "certificates", op: "create", method: "POST", path: "/certificates", args: nil}, {family: "certificates", op: "renew-all", method: "POST", path: "/certificates/renew", args: nil}, @@ -50,11 +52,23 @@ var generatedEndpoints = []endpoint{ {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: "cluster", op: "accept", method: "POST", path: "/cluster/accept", args: nil}, + {family: "cluster", op: "capacity", method: "GET", path: "/cluster/capacity", args: nil}, + {family: "cluster", op: "capacity-claim", method: "POST", path: "/cluster/capacity/claim", 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: "peers-policy", method: "GET", path: "/cluster/peers/:name/policy", args: []string{"name"}}, + {family: "cluster", op: "peers-policy-update", method: "PUT", path: "/cluster/peers/:name/policy", args: []string{"name"}}, + {family: "cluster", op: "peers-proxy-delete", method: "DELETE", path: "/cluster/peers/:name/proxy/*path", args: []string{"name", "path"}}, + {family: "cluster", op: "peers-proxy", method: "GET", path: "/cluster/peers/:name/proxy/*path", args: []string{"name", "path"}}, + {family: "cluster", op: "peers-proxy-patch", method: "PATCH", path: "/cluster/peers/:name/proxy/*path", args: []string{"name", "path"}}, + {family: "cluster", op: "peers-proxy-create", method: "POST", path: "/cluster/peers/:name/proxy/*path", args: []string{"name", "path"}}, + {family: "cluster", op: "peers-proxy-update", method: "PUT", path: "/cluster/peers/:name/proxy/*path", args: []string{"name", "path"}}, + {family: "cluster", op: "providers", method: "GET", path: "/cluster/providers", args: nil}, + {family: "cluster", op: "providers-update", method: "PUT", path: "/cluster/providers", args: nil}, + {family: "cluster", op: "setup", method: "POST", path: "/cluster/setup", args: nil}, {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}, @@ -64,6 +78,7 @@ var generatedEndpoints = []endpoint{ {family: "containers", op: "list", method: "GET", path: "/containers", args: nil}, {family: "containers", op: "stats", method: "GET", path: "/containers/stats", args: nil}, {family: "containers", op: "delete", method: "DELETE", path: "/containers/:id", args: []string{"id"}}, + {family: "containers", op: "capacity", method: "GET", path: "/containers/:id/capacity", 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"}}, @@ -104,10 +119,19 @@ var generatedEndpoints = []endpoint{ {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: "archive", method: "GET", path: "/deployments/:name/archive", args: []string{"name"}}, + {family: "deployments", op: "autoscale", method: "GET", path: "/deployments/:name/autoscale", args: []string{"name"}}, + {family: "deployments", op: "autoscale-update", method: "PUT", path: "/deployments/:name/autoscale", args: []string{"name"}}, + {family: "deployments", op: "autoscale-activate", method: "POST", path: "/deployments/:name/autoscale/activate", args: []string{"name"}}, + {family: "deployments", op: "autoscale-compatibility", method: "GET", path: "/deployments/:name/autoscale/compatibility", args: []string{"name"}}, + {family: "deployments", op: "autoscale-workload", method: "PUT", path: "/deployments/:name/autoscale/workload", 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: "backups-jobs", method: "GET", path: "/deployments/:name/backups/jobs/:id", args: []string{"name", "id"}}, + {family: "deployments", op: "backups-delete", method: "DELETE", path: "/deployments/:name/backups/:id", args: []string{"name", "id"}}, + {family: "deployments", op: "backups-download", method: "GET", path: "/deployments/:name/backups/:id/download", args: []string{"name", "id"}}, + {family: "deployments", op: "backups-restore", method: "POST", path: "/deployments/:name/backups/:id/restore", args: []string{"name", "id"}}, {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"}}, @@ -116,12 +140,14 @@ var generatedEndpoints = []endpoint{ {family: "deployments", op: "container-files-materialize", method: "POST", path: "/deployments/:name/container-files/:service/materialize", args: []string{"name", "service"}}, {family: "deployments", op: "copy", method: "POST", path: "/deployments/:name/copy", args: []string{"name"}}, {family: "deployments", op: "deploy", method: "POST", path: "/deployments/:name/deploy", args: []string{"name"}}, + {family: "deployments", op: "diagnostics", method: "GET", path: "/deployments/:name/diagnostics", 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: "env-variables", method: "PATCH", path: "/deployments/:name/env/variables", args: []string{"name"}}, {family: "deployments", op: "extract", method: "POST", path: "/deployments/:name/extract", 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"}}, @@ -172,7 +198,7 @@ var generatedEndpoints = []endpoint{ {family: "dns", op: "powerdns-zones-create", method: "POST", path: "/dns/powerdns/zones", args: nil}, {family: "dns", op: "powerdns-zones-delete", method: "DELETE", path: "/dns/powerdns/zones/:zoneId", args: []string{"zoneId"}}, {family: "dns", op: "powerdns-zones-get", method: "GET", path: "/dns/powerdns/zones/:zoneId", args: []string{"zoneId"}}, - {family: "dns", op: "powerdns-zones-update", method: "PATCH", path: "/dns/powerdns/zones/:zoneId", args: []string{"zoneId"}}, + {family: "dns", op: "powerdns-zones-patch", method: "PATCH", path: "/dns/powerdns/zones/:zoneId", args: []string{"zoneId"}}, {family: "dns", op: "providers", method: "GET", path: "/dns/providers", args: nil}, {family: "health", op: "list", method: "GET", path: "/health", args: nil}, {family: "images", op: "list", method: "GET", path: "/images", args: nil}, @@ -192,9 +218,13 @@ var generatedEndpoints = []endpoint{ {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: "incidents", method: "GET", path: "/notifications/incidents", args: nil}, + {family: "notifications", op: "rules", method: "GET", path: "/notifications/rules", args: nil}, + {family: "notifications", op: "rules-update", method: "PUT", path: "/notifications/rules", args: nil}, {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: "list", method: "GET", path: "/object-stores", args: nil}, {family: "object-stores", op: "provision-managed", method: "POST", path: "/object-stores/provision-managed", args: nil}, {family: "object-stores", op: "attach", method: "POST", path: "/object-stores/:name/attach", args: []string{"name"}}, {family: "object-stores", op: "buckets", method: "GET", path: "/object-stores/:name/buckets", args: []string{"name"}}, diff --git a/internal/command/root.go b/internal/command/root.go index 22b1fa9..5fa6579 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -16,7 +16,9 @@ import ( "github.com/flatrun/cli/internal/config" "github.com/flatrun/cli/internal/flatrun" + "github.com/flatrun/cli/internal/presentation" cliupdate "github.com/flatrun/cli/internal/update" + "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) @@ -179,6 +181,100 @@ type proxyStatusInfo struct { } func Run(args []string, stdout, stderr io.Writer) int { + exitCode := 0 + jsonListing := false + showVersion := false + root := &cobra.Command{ + Use: "flatrun", + Short: "Manage FlatRun servers and deployments", + SilenceErrors: true, + SilenceUsage: true, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + if jsonListing { + exitCode = runLegacy([]string{"--json"}, stdout, stderr) + return + } + if showVersion { + exitCode = runLegacy([]string{"--version"}, stdout, stderr) + return + } + usage(stdout) + }, + } + root.Flags().BoolVar(&jsonListing, "json", false, "Print the command catalog as JSON") + root.Flags().BoolVar(&showVersion, "version", false, "Print CLI version") + root.SetOut(stdout) + root.SetErr(stderr) + root.SetHelpFunc(func(cmd *cobra.Command, args []string) { usage(stdout) }) + legacyStderr := presentation.SemanticWriter(stderr) + + familyNames := families() + names := append([]string{}, familyNames...) + for _, family := range familyNames { + for _, candidate := range []string{strings.TrimSuffix(family, "s"), strings.TrimSuffix(family, "es")} { + if _, ok := resolveFamily(candidate); ok { + names = append(names, candidate) + } + } + } + names = append(names, "profile", "configure", "health", "deployment", "image", "container", "api", "version", "update") + seen := map[string]bool{} + for _, name := range names { + if seen[name] { + continue + } + seen[name] = true + commandName := name + root.AddCommand(&cobra.Command{ + Use: commandName, + DisableFlagParsing: true, + Args: cobra.ArbitraryArgs, + Run: func(cmd *cobra.Command, args []string) { + exitCode = runLegacy(append([]string{commandName}, args...), stdout, legacyStderr) + }, + }) + } + root.AddCommand(&cobra.Command{ + Use: "resources", + Short: "List every resource family", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + resourceUsage(stdout) + }, + }) + root.AddCommand(&cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion", + Args: cobra.ExactArgs(1), + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return root.GenBashCompletion(stdout) + case "zsh": + return root.GenZshCompletion(stdout) + case "fish": + return root.GenFishCompletion(stdout, true) + case "powershell": + return root.GenPowerShellCompletion(stdout) + } + return nil + }, + }) + root.SetArgs(args) + if err := root.Execute(); err != nil { + if len(args) > 0 && strings.Contains(err.Error(), "unknown command") { + _, _ = fmt.Fprintf(stderr, "Unknown command: %s\n", args[0]) + return 2 + } + _, _ = fmt.Fprintln(stderr, presentation.New(stderr).Error("Error: "+err.Error())) + return 2 + } + return exitCode +} + +func runLegacy(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { usage(stdout) return 0 @@ -199,6 +295,10 @@ func Run(args []string, stdout, stderr io.Writer) int { return runUpdate(args[1:], stdout, stderr) case "configure": return runConfigure(args[1:], stdout, stderr) + case "profile": + return runProfile(args[1:], stdout, stderr) + case "auth": + return runAuth(args[1:], stdout, stderr) case "health": return runHealth(args[1:], stdout, stderr) case "deployment": @@ -220,6 +320,12 @@ func Run(args []string, stdout, stderr io.Writer) int { } if singular, aliased := shapedAlias[family]; aliased { if len(args) > 1 && shapedCommand(singular, args[1]) { + if len(args) > 2 && (args[2] == "--help" || args[2] == "-h") { + return explainEndpoint(family, args[1], stdout, stderr) + } + if usesGenericEndpointFlags(args[2:]) { + return runEndpoint(family, args[1:], stdout, stderr) + } return runShaped(singular, args[1:], stdout, stderr) } if len(args) == 1 { @@ -231,26 +337,64 @@ func Run(args []string, stdout, stderr io.Writer) int { } func usage(w io.Writer) { - _, _ = fmt.Fprintln(w, "FlatRun CLI") - _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, "Usage:") - _, _ = fmt.Fprintln(w, " flatrun RESOURCE OPERATION [ARGS] [options]") + view := presentation.New(w) + _, _ = fmt.Fprintln(w, view.Title("FlatRun CLI")) + _, _ = fmt.Fprintln(w, view.Muted("Manage deployments and infrastructure from one terminal.")) _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, "Resources:") - for _, line := range wrapNames(families(), 88) { - _, _ = fmt.Fprintln(w, " "+line) - } + _, _ = fmt.Fprintln(w, view.Heading("Usage:")) + _, _ = fmt.Fprintln(w, " "+view.Command("flatrun RESOURCE OPERATION [ARGS] [options]")) _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, "Run `flatrun RESOURCE` for its operations. Singular and plural both work.") + _, _ = fmt.Fprintln(w, view.Heading("Start here:")) + view.Table(w, []string{"TASK", "COMMAND"}, [][]string{ + {"Show the active server", "flatrun profile current"}, + {"Check the connection", "flatrun health"}, + {"List deployments", "flatrun deployments list"}, + {"Explore all resources", "flatrun resources"}, + {"Learn an operation", "flatrun deployments create --help"}, + {"Check for CLI updates", "flatrun update --check"}, + }) _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, "Other commands:") - _, _ = fmt.Fprintln(w, " configure Save and switch between local profiles") - _, _ = fmt.Fprintln(w, " health Check that the agent is reachable") - _, _ = fmt.Fprintln(w, " api Call any endpoint directly") - _, _ = fmt.Fprintln(w, " version Print CLI version") - _, _ = fmt.Fprintln(w, " update Update the CLI to the latest release") + _, _ = fmt.Fprintln(w, view.Muted("Use --json for machine-readable output. Singular and plural resource names both work.")) +} + +func resourceUsage(w io.Writer) { + view := presentation.New(w) + _, _ = fmt.Fprintln(w, view.Title("FlatRun resources")) + _, _ = fmt.Fprintln(w, view.Muted("Run `flatrun RESOURCE` to list its operations.")) _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, "Add --json to any command for the raw answer, or to a listing for every command.") + groups := []struct { + name string + resources []string + }{ + {"Applications", []string{"deployments", "containers", "images", "compose", "templates"}}, + {"Infrastructure", []string{"cluster", "capacity", "databases", "networks", "ports", "proxy", "certificates", "dns", "volumes", "object-stores"}}, + {"Operations", []string{"backups", "plans", "scheduler", "notifications", "alerts", "audit", "stats", "traffic", "health"}}, + {"Access and security", []string{"auth", "users", "apikeys", "credentials", "registries", "source-credentials", "storage-credentials", "security"}}, + {"Platform", []string{"agent", "server", "settings", "config", "setup", "plugins", "ai", "dashboards", "system", "infrastructure", "subdomain", "backup-destinations"}}, + } + known := map[string]bool{} + for _, group := range groups { + for _, resource := range group.resources { + known[resource] = true + } + _, _ = fmt.Fprintln(w, view.Heading(group.name)) + for _, line := range wrapNames(group.resources, 88) { + _, _ = fmt.Fprintln(w, " "+line) + } + _, _ = fmt.Fprintln(w) + } + remaining := []string{} + for _, resource := range families() { + if !known[resource] { + remaining = append(remaining, resource) + } + } + if len(remaining) > 0 { + _, _ = fmt.Fprintln(w, view.Heading("Other")) + for _, line := range wrapNames(remaining, 88) { + _, _ = fmt.Fprintln(w, " "+line) + } + } } func runUpdate(args []string, stdout, stderr io.Writer) int { @@ -427,6 +571,136 @@ func runConfigure(args []string, stdout, stderr io.Writer) int { } } +func runProfile(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + _, _ = fmt.Fprintln(stderr, "Usage: flatrun profile ") + return 2 + } + switch args[0] { + case "add": + return runConfigureSet(args[1:], stdout, stderr) + case "use": + return runConfigureUse(args[1:], stdout, stderr) + case "list": + return runConfigureList(args[1:], stdout, stderr) + case "current": + return runProfileCurrent(args[1:], stdout, stderr) + case "remove": + return runConfigureDelete(args[1:], stdout, stderr) + default: + _, _ = fmt.Fprintf(stderr, "Unknown profile command: %s\n", args[0]) + return 2 + } +} + +func runProfileCurrent(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("profile current", flag.ContinueOnError) + fs.SetOutput(stderr) + if code, ok := parseFlagSet(fs, args); !ok { + return code + } + if fs.NArg() != 0 { + _, _ = fmt.Fprintln(stderr, "Usage: flatrun profile current") + return 2 + } + cfg, err := config.Load(config.DefaultPath()) + if err != nil { + _, _ = fmt.Fprintln(stderr, "Error:", err) + return 1 + } + profile, ok := cfg.Profiles[cfg.CurrentProfile] + if !ok { + _, _ = fmt.Fprintln(stderr, "Error: no active profile") + return 1 + } + _, _ = fmt.Fprintf(stdout, "%s\t%s\n", cfg.CurrentProfile, profile.URL) + return 0 +} + +func runAuth(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + return runEndpoint("auth", args, stdout, stderr) + } + if args[0] != "login" { + return runEndpoint("auth", args, stdout, stderr) + } + return runAuthLogin(args[1:], stdout, stderr) +} + +func runAuthLogin(args []string, stdout, stderr io.Writer) int { + profileName := "" + username := "" + passwordStdin := false + apiKeyStdin := false + fs := flag.NewFlagSet("auth login", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.StringVar(&profileName, "profile", "", "Profile name") + fs.StringVar(&username, "username", "", "Username") + fs.BoolVar(&passwordStdin, "password-stdin", false, "Read the password from stdin") + fs.BoolVar(&apiKeyStdin, "api-key-stdin", false, "Read an API key from stdin") + if code, ok := parseFlagSet(fs, args); !ok { + return code + } + if passwordStdin == apiKeyStdin || (passwordStdin && username == "") || (apiKeyStdin && username != "") { + _, _ = fmt.Fprintln(stderr, "Usage: flatrun auth login [--profile PROFILE] (--username USERNAME --password-stdin | --api-key-stdin)") + return 2 + } + if stdinIsTerminal() { + _, _ = fmt.Fprintln(stderr, "Error: --password-stdin requires piped input") + return 2 + } + credential, err := io.ReadAll(stdin) + if err != nil { + _, _ = fmt.Fprintln(stderr, "Error:", err) + return 1 + } + + path := config.DefaultPath() + cfg, err := config.Load(path) + if err != nil { + _, _ = fmt.Fprintln(stderr, "Error:", err) + return 1 + } + if profileName == "" { + profileName = cfg.CurrentProfile + } + profile, ok := cfg.Profiles[profileName] + if !ok || profile.URL == "" { + _, _ = fmt.Fprintf(stderr, "Error: profile %q does not exist\n", profileName) + return 1 + } + + client := flatrun.New(profile.URL, "", 30*time.Second, false) + body := map[string]string{} + if apiKeyStdin { + body["api_key"] = strings.TrimRight(string(credential), "\r\n") + } else { + body["username"] = username + body["password"] = strings.TrimRight(string(credential), "\r\n") + } + data, err := client.Raw(context.Background(), http.MethodPost, "/auth/login", body) + if err != nil { + _, _ = fmt.Fprintln(stderr, "Error:", err) + return 1 + } + var response struct { + Token string `json:"token"` + } + if err := json.Unmarshal(data, &response); err != nil || response.Token == "" { + _, _ = fmt.Fprintln(stderr, "Error: login response did not include a token") + return 1 + } + profile.Token = response.Token + cfg.Profiles[profileName] = profile + cfg.CurrentProfile = profileName + if err := config.Save(path, cfg); err != nil { + _, _ = fmt.Fprintln(stderr, "Error:", err) + return 1 + } + _, _ = fmt.Fprintf(stdout, "Logged in to profile %q\n", profileName) + return 0 +} + func runConfigureSet(args []string, stdout, stderr io.Writer) int { profileName := "default" urlValue := "" @@ -467,6 +741,10 @@ func runConfigureSet(args []string, stdout, stderr io.Writer) int { return 2 } + client := flatrun.New(urlValue, token, 30*time.Second, false) + health, healthErr := client.Health(context.Background()) + agentVersion := agentVersionFromHealth(health) + path := config.DefaultPath() cfg, err := config.Load(path) if err != nil { @@ -480,9 +758,54 @@ func runConfigureSet(args []string, stdout, stderr io.Writer) int { return 1 } _, _ = fmt.Fprintf(stdout, "Saved profile %q\n", profileName) + if agentVersion != "" { + _, _ = fmt.Fprintf(stdout, "Connected to FlatRun agent %s with CLI %s\n", agentVersion, Version) + if clientTrackNewer(Version, agentVersion) { + _, _ = fmt.Fprintf(stderr, "Warning: CLI %s is newer than agent %s. Update the agent before relying on newer commands.\n", Version, agentVersion) + } + } else if healthErr != nil { + _, _ = fmt.Fprintf(stderr, "Warning: profile saved, but the agent version could not be read: %v\n", healthErr) + } return 0 } +func agentVersionFromHealth(data []byte) string { + var response struct { + Version json.RawMessage `json:"version"` + } + if json.Unmarshal(data, &response) != nil { + return "" + } + var version string + if json.Unmarshal(response.Version, &version) == nil { + return version + } + var details struct { + Version string `json:"version"` + } + if json.Unmarshal(response.Version, &details) == nil { + return details.Version + } + return "" +} + +func clientTrackNewer(clientVersion, agentVersion string) bool { + parse := func(value string) (int, int, bool) { + value = strings.TrimPrefix(value, "v") + var major, minor int + if _, err := fmt.Sscanf(value, "%d.%d", &major, &minor); err != nil { + return 0, 0, false + } + return major, minor, true + } + clientMajor, clientMinor, clientOK := parse(clientVersion) + agentMajor, agentMinor, agentOK := parse(agentVersion) + if !clientOK || !agentOK { + return false + } + return clientMajor > agentMajor || clientMajor == agentMajor && clientMinor > agentMinor +} + func runConfigureList(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("configure list", flag.ContinueOnError) fs.SetOutput(stderr) @@ -611,6 +934,11 @@ func runDeployment(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { return listEndpoints(stdout, stderr, "deployment", false) } + if len(args) > 1 && shapedCommand("deployment", args[0]) { + if args[1] == "--generate-cli-skeleton" { + return runEndpoint("deployments", args, stdout, stderr) + } + } switch args[0] { case "help", "-h", "--help": @@ -949,7 +1277,12 @@ func runDeploymentImageSet(args []string, stdout, stderr io.Writer) int { return 1 } - data, err = client.UpdateDeploymentCompose(context.Background(), deploymentName, updated) + variable := composeImageVariable(oldImage) + if variable != "" { + data, err = updateDeploymentImageVariable(context.Background(), client, deploymentName, variable, imageName) + } else { + data, err = client.UpdateDeploymentCompose(context.Background(), deploymentName, updated) + } if err != nil { _, _ = fmt.Fprintln(stderr, "Error:", err) return 1 @@ -981,6 +1314,52 @@ func runDeploymentImageSet(args []string, stdout, stderr io.Writer) int { return 0 } +func composeImageVariable(image string) string { + if !strings.HasPrefix(image, "${") || !strings.HasSuffix(image, "}") { + return "" + } + name := strings.TrimSuffix(strings.TrimPrefix(image, "${"), "}") + if before, _, found := strings.Cut(name, ":-"); found { + name = before + } + if name == "" { + return "" + } + for i, r := range name { + letter := r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' + digit := i > 0 && r >= '0' && r <= '9' + if !letter && !digit { + return "" + } + } + return name +} + +func updateDeploymentImageVariable(ctx context.Context, client *flatrun.Client, deployment, variable, image string) ([]byte, error) { + data, err := client.GetDeploymentEnv(ctx, deployment) + if err != nil { + return nil, err + } + var response struct { + EnvVars []flatrun.EnvVar `json:"env_vars"` + } + if err := json.Unmarshal(data, &response); err != nil { + return nil, err + } + found := false + for i := range response.EnvVars { + if response.EnvVars[i].Key == variable { + response.EnvVars[i].Value = image + found = true + break + } + } + if !found { + response.EnvVars = append(response.EnvVars, flatrun.EnvVar{Key: variable, Value: image}) + } + return client.UpdateDeploymentEnv(ctx, deployment, response.EnvVars) +} + func runDeploymentCreate(args []string, stdout, stderr io.Writer) int { opts := globalOptions{} image := "" @@ -1374,24 +1753,25 @@ func printResponse(stdout io.Writer, rawJSON bool, data []byte, fallback string) _, _ = fmt.Fprintln(stdout, string(data)) return } + view := presentation.New(stdout) var response map[string]any if err := json.Unmarshal(data, &response); err != nil { if fallback == "" { _, _ = fmt.Fprintln(stdout, string(data)) return } - _, _ = fmt.Fprintln(stdout, fallback) + _, _ = fmt.Fprintln(stdout, view.Success(fallback)) return } if message, ok := response["message"].(string); ok && strings.TrimSpace(message) != "" { - _, _ = fmt.Fprintln(stdout, message) + _, _ = fmt.Fprintln(stdout, view.Success(message)) return } if status, ok := response["status"].(string); ok && strings.TrimSpace(status) != "" && fallback != "" { - _, _ = fmt.Fprintf(stdout, "%s: %s\n", fallback, status) + _, _ = fmt.Fprintf(stdout, "%s: %s\n", view.Success(fallback), view.Status(status)) return } - _, _ = fmt.Fprintln(stdout, fallback) + _, _ = fmt.Fprintln(stdout, view.Success(fallback)) } func renderDeploymentList(stdout io.Writer, data []byte) error { @@ -1783,12 +2163,7 @@ func databaseSummary(deployment deploymentInfo) string { } func writeTable(stdout io.Writer, headers []string, tableRows [][]string) { - tw := tabwriter.NewWriter(stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintln(tw, strings.Join(headers, "\t")) - for _, row := range tableRows { - _, _ = fmt.Fprintln(tw, strings.Join(row, "\t")) - } - _ = tw.Flush() + presentation.New(stdout).Table(stdout, headers, tableRows) } func boolText(value bool) string { diff --git a/internal/command/root_test.go b/internal/command/root_test.go index 1e35076..fbf34d4 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -13,8 +13,44 @@ import ( "regexp" "strings" "testing" + + "github.com/flatrun/cli/internal/flatrun" ) +func TestLandingPageShowsHowToGetOperationHelp(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := Run(nil, &stdout, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + for _, text := range []string{"Start here:", "flatrun resources", "deployments create --help"} { + if !strings.Contains(stdout.String(), text) { + t.Fatalf("landing page missing %q:\n%s", text, stdout.String()) + } + } +} + +func TestResourcesPageGroupsTheCommandSurface(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := Run([]string{"resources"}, &stdout, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + for _, text := range []string{"Applications", "Infrastructure", "Operations", "Access and security", "Platform", "deployments", "notifications"} { + if !strings.Contains(stdout.String(), text) { + t.Fatalf("resources page missing %q", text) + } + } +} + +func TestCobraGeneratesShellCompletion(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := Run([]string{"completion", "bash"}, &stdout, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "__start_flatrun") { + t.Fatalf("completion output is incomplete") + } +} + func TestDeploymentFilesPushSendsOneArchiveRequest(t *testing.T) { source := t.TempDir() if err := os.Mkdir(filepath.Join(source, "assets"), 0o755); err != nil { @@ -119,6 +155,97 @@ func TestConfigureSetAndList(t *testing.T) { } } +func TestProfileCommandsManageCurrentProfile(t *testing.T) { + t.Setenv("FLATRUN_CONFIG", filepath.Join(t.TempDir(), "config.json")) + var stdout, stderr bytes.Buffer + + if code := Run([]string{"profile", "add", "--profile", "local", "--url", "https://local.example", "--token", "secret"}, &stdout, &stderr); code != 0 { + t.Fatalf("profile add code=%d stderr=%s", code, stderr.String()) + } + stdout.Reset() + stderr.Reset() + if code := Run([]string{"profile", "current"}, &stdout, &stderr); code != 0 { + t.Fatalf("profile current code=%d stderr=%s", code, stderr.String()) + } + if got := stdout.String(); got != "local\thttps://local.example\n" { + t.Fatalf("profile current output = %q", got) + } +} + +func TestAuthLoginStoresSessionTokenForProfile(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/health": + _, _ = w.Write([]byte(`{"agent":"flatrun","status":"healthy","version":{"version":"0.4.0-beta.7"}}`)) + case "/api/auth/login": + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["username"] != "admin" || body["password"] != " correct-password " { + t.Fatalf("login body = %#v", body) + } + _, _ = w.Write([]byte(`{"token":"session-token","expires_in":86400,"token_type":"Bearer"}`)) + default: + t.Fatalf("path = %s", r.URL.Path) + } + })) + defer server.Close() + + configPath := filepath.Join(t.TempDir(), "config.json") + t.Setenv("FLATRUN_CONFIG", configPath) + var stdout, stderr bytes.Buffer + if code := Run([]string{"profile", "add", "--profile", "local", "--url", server.URL, "--token", "bootstrap"}, &stdout, &stderr); code != 0 { + t.Fatalf("profile add code=%d stderr=%s", code, stderr.String()) + } + previousStdin := stdin + previousTerminal := stdinIsTerminal + stdin = strings.NewReader(" correct-password \r\n") + stdinIsTerminal = func() bool { return false } + t.Cleanup(func() { + stdin = previousStdin + stdinIsTerminal = previousTerminal + }) + stdout.Reset() + stderr.Reset() + if code := Run([]string{"auth", "login", "--profile", "local", "--username", "admin", "--password-stdin"}, &stdout, &stderr); code != 0 { + t.Fatalf("auth login code=%d stderr=%s", code, stderr.String()) + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"token": "session-token"`) { + t.Fatalf("saved config = %s", data) + } +} + +func TestConfigureSetReportsAgentAndClientVersions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/health" { + t.Fatalf("path = %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"status":"healthy","agent":"flatrun","version":"0.3.9"}`)) + })) + defer server.Close() + t.Setenv("FLATRUN_CONFIG", filepath.Join(t.TempDir(), "config.json")) + previousVersion := Version + Version = "0.4.0" + t.Cleanup(func() { Version = previousVersion }) + + var stdout, stderr bytes.Buffer + code := Run([]string{"configure", "set", "--url", server.URL, "--token", "secret"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "Connected to FlatRun agent 0.3.9 with CLI 0.4.0") { + t.Fatalf("stdout = %s", stdout.String()) + } + if !strings.Contains(stderr.String(), "CLI 0.4.0 is newer than agent 0.3.9") { + t.Fatalf("stderr = %s", stderr.String()) + } +} + func TestConfigureSetReadsTokenFromStdin(t *testing.T) { t.Setenv("FLATRUN_CONFIG", filepath.Join(t.TempDir(), "config.json")) oldStdin := stdin @@ -356,7 +483,7 @@ func TestHelpShowsResourceCommands(t *testing.T) { if code != 0 { t.Fatalf("code=%d stderr=%s", code, stderr.String()) } - for _, want := range []string{"deployment", "image", "container", "api"} { + for _, want := range []string{"profile current", "health", "deployments list", "resources"} { if !strings.Contains(stdout.String(), want) { t.Fatalf("help missing %q:\n%s", want, stdout.String()) } @@ -866,6 +993,43 @@ func TestDeploymentImageSetCanDeployAfterUpdate(t *testing.T) { } } +func TestDeploymentImageSetUpdatesReferencedEnvironmentVariable(t *testing.T) { + requests := []string{} + var envPayload struct { + EnvVars []flatrun.EnvVar `json:"env_vars"` + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.Path) + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/deployments/api/compose": + _, _ = w.Write([]byte(`{"content":"services:\n app:\n image: ${ENTERPRISE_IMAGE}\n"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/deployments/api/env": + _, _ = w.Write([]byte(`{"env_vars":[{"key":"ENTERPRISE_IMAGE","value":"ghcr.io/acme/api:old"},{"key":"OTHER","value":"keep"}]}`)) + case r.Method == http.MethodPut && r.URL.Path == "/api/deployments/api/env": + if err := json.NewDecoder(r.Body).Decode(&envPayload); err != nil { + t.Fatalf("decode environment payload: %v", err) + } + _, _ = w.Write([]byte(`{"message":"Environment variables updated"}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + t.Setenv("FLATRUN_URL", server.URL) + t.Setenv("FLATRUN_TOKEN", "secret") + var stdout, stderr bytes.Buffer + if code := Run([]string{"deployment", "image", "set", "api", "app", "ghcr.io/acme/api:new"}, &stdout, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if strings.Join(requests, "\n") != "GET /api/deployments/api/compose\nGET /api/deployments/api/env\nPUT /api/deployments/api/env" { + t.Fatalf("requests = %#v", requests) + } + if len(envPayload.EnvVars) != 2 || envPayload.EnvVars[0].Value != "ghcr.io/acme/api:new" || envPayload.EnvVars[1].Value != "keep" { + t.Fatalf("environment payload = %#v", envPayload.EnvVars) + } +} + func TestSetComposeServiceImageRejectsMissingService(t *testing.T) { _, _, err := setComposeServiceImage("services:\n app:\n image: old\n", "worker", "new") if err == nil { diff --git a/internal/command/schema.go b/internal/command/schema.go index 0363abb..9f589a6 100644 --- a/internal/command/schema.go +++ b/internal/command/schema.go @@ -11,6 +11,7 @@ import ( "text/tabwriter" "time" + "github.com/flatrun/cli/internal/presentation" "github.com/flatrun/cli/internal/spec" ) @@ -120,26 +121,37 @@ func distance(a, b string) int { } func describeEndpoint(w io.Writer, api *spec.Spec, e endpoint, op spec.Operation) { - _, _ = fmt.Fprintln(w, invocation(e)) + view := presentation.New(w) + _, _ = fmt.Fprintln(w, view.Title(invocation(e))) if op.Permission != "" { - _, _ = fmt.Fprintf(w, "Needs %s\n", op.Permission) + _, _ = fmt.Fprintf(w, "%s %s\n", view.Muted("Permission:"), op.Permission) + } + if op.PlanSupported { + _, _ = fmt.Fprintln(w, view.Success("Supports --plan to preview every change before applying it.")) } 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) + _, _ = fmt.Fprintln(w, "\n"+view.Heading("Request fields")) + rows := make([][]string, 0, len(fields)) 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) + help := field.Help + if len(field.Accepted) > 0 { + help = "Accepted: " + strings.Join(field.Accepted, ", ") + } + rows = append(rows, []string{field.Name, field.Type, required, help}) } - _ = tw.Flush() + presentation.New(w).Table(w, []string{"FIELD", "TYPE", "REQUIRED", "DETAILS"}, rows) } if query := api.QueryParams(op); len(query) > 0 { - _, _ = fmt.Fprintf(w, "\nQuery parameters, given as -q name=value:\n %s\n", strings.Join(query, ", ")) + _, _ = fmt.Fprintf(w, "\n%s\n %s\n", view.Heading("Query parameters (-q name=value)"), strings.Join(query, ", ")) + } + if len(api.Fields(op)) > 0 { + _, _ = fmt.Fprintln(w, "\n"+view.Muted("Print a request template with --generate-cli-skeleton.")) } } diff --git a/internal/command/schema_test.go b/internal/command/schema_test.go index efd2012..4519c6a 100644 --- a/internal/command/schema_test.go +++ b/internal/command/schema_test.go @@ -17,10 +17,22 @@ const testSpec = `{ "openapi": "3.1.0", "info": {"version": "0.4.0"}, "paths": { + "/api/deployments": { + "post": { + "operationId": "post-deployments", + "requestBody": {"required": true, "content": {"application/json": { + "schema": {"type": "object", "required": ["name"], "properties": { + "name": {"type": "string"}, "image": {"type": "string"} + }} + }}}, + "responses": {"201": {"description": "Created"}} + } + }, "/api/backups": { "post": { "operationId": "post-backups", "x-permission": "backups:write", + "x-plan-supported": true, "requestBody": {"required": true, "content": {"application/json": { "schema": {"$ref": "#/components/schemas/backup.CreateBackupRequest"}}}}, "responses": {"200": {"description": "Success"}} @@ -188,13 +200,109 @@ func TestHelpShowsWhatAnEndpointTakes(t *testing.T) { if code != 0 { t.Fatalf("code=%d stderr=%s", code, stderr) } - for _, want := range []string{"deployment_name", "required", "description", "backups:write"} { + for _, want := range []string{"deployment_name", "required", "description", "backups:write", "--plan", "--generate-cli-skeleton"} { if !strings.Contains(stdout, want) { t.Errorf("help should mention %q, got:\n%s", want, stdout) } } } +func TestGenerateRequestSkeleton(t *testing.T) { + isolateCache(t) + server, got := describingServer(t, `{}`) + + code, stdout, stderr := runCLI(t, server, "backups", "create", "--generate-cli-skeleton") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if got.method != "" { + t.Fatalf("skeleton made an API request with %s", got.method) + } + var body map[string]any + if err := json.Unmarshal([]byte(stdout), &body); err != nil { + t.Fatalf("skeleton is not JSON: %v: %s", err, stdout) + } + if body["deployment_name"] != "" || body["description"] != "" { + t.Fatalf("unexpected skeleton: %#v", body) + } +} + +func TestGenerateRequestSkeletonStopsAtRecursiveSchema(t *testing.T) { + isolateCache(t) + const recursiveSpec = `{ + "openapi": "3.1.0", + "info": {"version": "0.4.0"}, + "paths": {"/api/backups": {"post": { + "operationId": "post-backups", + "requestBody": {"required": true, "content": {"application/json": { + "schema": {"$ref": "#/components/schemas/Request"} + }}}, + "responses": {"200": {"description": "Success"}} + }}}, + "components": {"schemas": {"Request": { + "type": "object", + "properties": { + "deployment_name": {"type": "string"}, + "level": {"type": "integer", "enum": [1, 2]}, + "parent": {"$ref": "#/components/schemas/Request"} + } + }}} +}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/openapi.json") { + _, _ = w.Write([]byte(recursiveSpec)) + return + } + t.Fatal("skeleton made an API request") + })) + t.Cleanup(server.Close) + + code, stdout, stderr := runCLI(t, server, "backups", "create", "--generate-cli-skeleton") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + var body map[string]any + if err := json.Unmarshal([]byte(stdout), &body); err != nil { + t.Fatalf("skeleton is not JSON: %v: %s", err, stdout) + } + if body["deployment_name"] != "" || body["level"] != float64(1) || body["parent"] != nil { + t.Fatalf("skeleton = %#v", body) + } +} + +func TestPlanAddsDocumentedQuery(t *testing.T) { + isolateCache(t) + server, got := describingServer(t, `{}`) + + code, _, stderr := runCLI(t, server, "backups", "create", "--plan", "-f", "deployment_name=shop") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if got.query != "plan=true" { + t.Fatalf("query = %q", got.query) + } +} + +func TestPluralShapedCommandCanPrintGeneratedSkeleton(t *testing.T) { + isolateCache(t) + server, got := describingServer(t, `{}`) + + code, stdout, stderr := runCLI(t, server, "deployments", "create", "--generate-cli-skeleton") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if got.path != "" { + t.Fatalf("skeleton made a mutation request to %s", got.path) + } + var body map[string]any + if err := json.Unmarshal([]byte(stdout), &body); err != nil { + t.Fatalf("skeleton is not JSON: %v: %s", err, stdout) + } + if _, ok := body["name"]; !ok { + t.Fatalf("skeleton = %#v", body) + } +} + // An older agent that cannot describe itself still has to work. func TestCommandsWorkWithoutADescription(t *testing.T) { isolateCache(t) diff --git a/internal/flatrun/client.go b/internal/flatrun/client.go index 1ef4621..999be3d 100644 --- a/internal/flatrun/client.go +++ b/internal/flatrun/client.go @@ -99,6 +99,19 @@ func (c *Client) UpdateDeploymentCompose(ctx context.Context, name, composeConte }) } +type EnvVar struct { + Key string `json:"key"` + Value string `json:"value"` +} + +func (c *Client) GetDeploymentEnv(ctx context.Context, name string) ([]byte, error) { + return c.Do(ctx, http.MethodGet, "/deployments/"+url.PathEscape(name)+"/env", nil) +} + +func (c *Client) UpdateDeploymentEnv(ctx context.Context, name string, envVars []EnvVar) ([]byte, error) { + return c.Do(ctx, http.MethodPut, "/deployments/"+url.PathEscape(name)+"/env", map[string]any{"env_vars": envVars}) +} + type DeployRequest struct { Action string `json:"action"` Pull bool `json:"pull"` diff --git a/internal/presentation/presentation.go b/internal/presentation/presentation.go new file mode 100644 index 0000000..84312f3 --- /dev/null +++ b/internal/presentation/presentation.go @@ -0,0 +1,140 @@ +package presentation + +import ( + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" + "golang.org/x/term" +) + +type Renderer struct { + color bool + width int +} + +type semanticWriter struct { + writer io.Writer + renderer Renderer +} + +func SemanticWriter(w io.Writer) io.Writer { + return semanticWriter{writer: w, renderer: New(w)} +} + +func (w semanticWriter) Write(value []byte) (int, error) { + text := string(value) + styled := text + switch { + case strings.HasPrefix(text, "Error:"), strings.HasPrefix(text, "Unknown command:"): + styled = w.renderer.Error(strings.TrimSuffix(text, "\n")) + newline(text) + case strings.HasPrefix(text, "Warning:"): + styled = w.renderer.Warning(strings.TrimSuffix(text, "\n")) + newline(text) + } + _, err := io.WriteString(w.writer, styled) + if err != nil { + return 0, err + } + return len(value), nil +} + +func newline(value string) string { + if strings.HasSuffix(value, "\n") { + return "\n" + } + return "" +} + +func New(w io.Writer) Renderer { + file, ok := w.(*os.File) + width := 0 + if ok { + width, _, _ = term.GetSize(int(file.Fd())) + } + return Renderer{color: ok && term.IsTerminal(int(file.Fd())) && os.Getenv("NO_COLOR") == "" && os.Getenv("TERM") != "dumb", width: width} +} + +func (r Renderer) Title(value string) string { + return r.render(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")), value) +} +func (r Renderer) Heading(value string) string { + return r.render(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("14")), value) +} +func (r Renderer) Command(value string) string { + return r.render(lipgloss.NewStyle().Foreground(lipgloss.Color("12")), value) +} +func (r Renderer) Muted(value string) string { return r.render(lipgloss.NewStyle().Faint(true), value) } +func (r Renderer) Success(value string) string { + return r.render(lipgloss.NewStyle().Foreground(lipgloss.Color("10")), value) +} +func (r Renderer) Warning(value string) string { + return r.render(lipgloss.NewStyle().Foreground(lipgloss.Color("11")), value) +} +func (r Renderer) Error(value string) string { + return r.render(lipgloss.NewStyle().Foreground(lipgloss.Color("9")), value) +} + +func (r Renderer) Status(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "healthy", "running", "ready", "active", "succeeded", "success", "online", "yes": + return r.Success(value) + case "starting", "pending", "warning", "attention", "unknown": + return r.Warning(value) + case "unhealthy", "failed", "error", "offline", "stopped", "no": + return r.Error(value) + default: + return value + } +} + +func (r Renderer) Table(w io.Writer, headers []string, rows [][]string) { + if !r.color { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, strings.Join(headers, "\t")) + for _, row := range rows { + _, _ = fmt.Fprintln(tw, strings.Join(row, "\t")) + } + _ = tw.Flush() + return + } + styledRows := make([][]string, len(rows)) + for i, row := range rows { + styledRows[i] = append([]string(nil), row...) + for column, header := range headers { + if column < len(styledRows[i]) && isStatusColumn(header) { + styledRows[i][column] = r.Status(styledRows[i][column]) + } + } + } + t := table.New().Border(lipgloss.HiddenBorder()).BorderTop(false).BorderBottom(false).BorderLeft(false).BorderRight(false).BorderHeader(false).BorderColumn(false).BorderRow(false).Headers(headers...).Rows(styledRows...).StyleFunc(func(row, _ int) lipgloss.Style { + if row == table.HeaderRow { + return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("14")).PaddingRight(2) + } + return lipgloss.NewStyle().PaddingRight(2) + }) + rendered := t.Render() + if r.width > 0 && lipgloss.Width(rendered) > r.width { + rendered = t.Width(r.width).Render() + } + _, _ = fmt.Fprintln(w, rendered) +} + +func (r Renderer) render(style lipgloss.Style, value string) string { + if !r.color { + return value + } + return style.Render(value) +} + +func isStatusColumn(header string) bool { + switch strings.ToUpper(header) { + case "STATUS", "STATE", "HEALTH", "LATEST", "ACTIVE": + return true + default: + return false + } +} diff --git a/internal/presentation/presentation_test.go b/internal/presentation/presentation_test.go new file mode 100644 index 0000000..76ca230 --- /dev/null +++ b/internal/presentation/presentation_test.go @@ -0,0 +1,52 @@ +package presentation + +import ( + "bytes" + "strings" + "testing" + + "charm.land/lipgloss/v2" +) + +func TestPlainRendererDoesNotEmitANSI(t *testing.T) { + var output bytes.Buffer + renderer := New(&output) + renderer.Table(&output, []string{"NAME", "STATUS"}, [][]string{{"api", "running"}}) + if strings.Contains(output.String(), "\x1b[") { + t.Fatalf("plain output contains ANSI: %q", output.String()) + } + if output.String() != "NAME STATUS\napi running\n" { + t.Fatalf("plain table = %q", output.String()) + } +} + +func TestColoredTableFitsConfiguredWidth(t *testing.T) { + var output bytes.Buffer + renderer := Renderer{color: true, width: 28} + renderer.Table(&output, []string{"NAME", "STATUS", "DESCRIPTION"}, [][]string{{"api", "running", "A description that must wrap"}}) + for _, line := range strings.Split(strings.TrimSuffix(output.String(), "\n"), "\n") { + if width := lipgloss.Width(line); width > 28 { + t.Fatalf("line width = %d, line = %q", width, line) + } + } +} + +func TestColorRendererStylesSemanticOutput(t *testing.T) { + renderer := Renderer{color: true} + for _, value := range []string{renderer.Title("FlatRun CLI"), renderer.Status("healthy"), renderer.Status("failed")} { + if !strings.Contains(value, "\x1b[") { + t.Fatalf("styled output has no ANSI: %q", value) + } + } +} + +func TestSemanticWriterPreservesPlainErrors(t *testing.T) { + var output bytes.Buffer + writer := SemanticWriter(&output) + if _, err := writer.Write([]byte("Error: unavailable\n")); err != nil { + t.Fatal(err) + } + if output.String() != "Error: unavailable\n" { + t.Fatalf("output = %q", output.String()) + } +} diff --git a/internal/spec/spec.go b/internal/spec/spec.go index a999f5d..9b52016 100644 --- a/internal/spec/spec.go +++ b/internal/spec/spec.go @@ -33,7 +33,65 @@ type Operation struct { Schema *Schema `json:"schema"` } `json:"content"` } `json:"responses"` - Permission string `json:"x-permission"` + Permission string `json:"x-permission"` + PlanSupported bool `json:"x-plan-supported"` +} + +func (s *Spec) Skeleton(op Operation) map[string]any { + result := map[string]any{} + if op.RequestBody == nil { + return result + } + content, ok := op.RequestBody.Content["application/json"] + if !ok { + return result + } + schema := s.Resolve(content.Schema) + if schema == nil { + return result + } + for _, field := range s.Fields(op) { + result[field.Name] = s.skeletonValue(schema.Properties[field.Name], map[*Schema]bool{schema: true}) + } + return result +} + +func (s *Spec) skeletonValue(schema *Schema, visiting map[*Schema]bool) any { + schema = s.Resolve(schema) + if schema == nil { + return nil + } + if visiting[schema] { + return nil + } + visiting[schema] = true + defer delete(visiting, schema) + if len(schema.Enum) > 0 { + return schema.Enum[0] + } + switch schema.Type { + case "boolean": + return false + case "integer", "number": + return 0 + case "array": + item := s.Resolve(schema.Items) + if item != nil && (item.Type == "object" || len(item.Properties) > 0) { + if visiting[item] { + return []any{} + } + return []any{s.skeletonValue(item, visiting)} + } + return []any{} + case "object": + value := map[string]any{} + for name, property := range schema.Properties { + value[name] = s.skeletonValue(property, visiting) + } + return value + default: + return "" + } } type Parameter struct { @@ -62,6 +120,7 @@ type Schema struct { Required []string `json:"required"` Description string `json:"description"` AdditionalProperties *Schema `json:"additionalProperties"` + Enum []any `json:"enum"` } func Parse(raw []byte) (*Spec, error) { @@ -108,6 +167,7 @@ type Field struct { Type string Required bool Help string + Accepted []string } // Fields are an endpoint's body fields in declaration order, so help reads the way the type does. @@ -148,11 +208,27 @@ func (s *Spec) Fields(op Operation) []Field { Type: typeName(property), Required: required[name], Help: property.Description, + Accepted: enumStrings(property.Enum), }) } return fields } +func enumStrings(values []any) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + if text, ok := value.(string); ok { + result = append(result, text) + continue + } + encoded, err := json.Marshal(value) + if err == nil { + result = append(result, string(encoded)) + } + } + return result +} + func typeName(schema *Schema) string { switch schema.Type { case "array": diff --git a/tools/gen_endpoints.py b/tools/gen_endpoints.py index 23935cb..a47269d 100644 --- a/tools/gen_endpoints.py +++ b/tools/gen_endpoints.py @@ -11,7 +11,7 @@ import json import sys -WRITE_VERB = {"POST": "create", "PUT": "update", "PATCH": "update", "DELETE": "delete"} +WRITE_VERB = {"POST": "create", "PUT": "update", "PATCH": "patch", "DELETE": "delete"} # Reached by the agent's own components, not by an operator. SKIP_PREFIXES = ("/api/internal", "/api/_internal", "/api/security/events/ingest", "/api/traffic/ingest")