From 5ab9f086a28f4eb0ad1424c3900ca932fbcdf10a Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 15 Aug 2026 11:19:53 +0100 Subject: [PATCH 1/4] fix: Give a pipe the answer, not a table Commands that had always printed raw JSON started printing tables, which reads better on screen and breaks anything parsing the output, including every workflow using this CLI today. A table is now for a terminal. A pipe, a redirect or a CI step gets the raw answer, which is what it got before. --- CHANGELOG.md | 2 ++ README.md | 3 ++- docs/reference/commands.md | 3 ++- internal/command/endpoints_test.go | 32 ++++++++++++++++++++++++++++++ internal/command/root.go | 9 ++++++++- internal/command/root_test.go | 2 ++ 6 files changed, 48 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ab2548..f77c94c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ All notable changes to the FlatRun CLI are documented in this file. - 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. +- Output is laid out for whoever is reading it: a table on a terminal, and the raw answer when the output is piped or redirected, which is what a script parsed before. `--json` forces the raw answer either way. + ### 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/README.md b/README.md index d8c419f..8f441e1 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,8 @@ sends a number. If the agent describes the answer, that decides the layout. Otherwise an array of objects prints as a table, an array of scalars one per line, an empty one as `None`, and anything else as raw JSON. -`--json` overrides all of it. +Piping or redirecting gets the raw answer instead, since something is parsing it. `--json` forces +it either way. ### Driving it from a script or an agent diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 5466d98..9d4745b 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -180,7 +180,8 @@ maps the JSON itself: | Empty array | `None` | | No array, or more than one | The raw JSON | -`--json` overrides all of it and prints the answer untouched. +None of it applies when the output is not a terminal: a pipe, a redirect or a CI step gets the raw +answer, since something is parsing it. `--json` forces the raw answer either way. ## Listing what exists diff --git a/internal/command/endpoints_test.go b/internal/command/endpoints_test.go index 1bc9f0b..fbc9d73 100644 --- a/internal/command/endpoints_test.go +++ b/internal/command/endpoints_test.go @@ -10,6 +10,14 @@ import ( "testing" ) +// asTerminal runs a command as though someone were watching, which is when a table is wanted. +func asTerminal(t *testing.T) { + t.Helper() + previous := stdoutIsTerminal + stdoutIsTerminal = func() bool { return true } + t.Cleanup(func() { stdoutIsTerminal = previous }) +} + type recordedRequest struct { method string path string @@ -39,6 +47,7 @@ func runCLI(t *testing.T, server *httptest.Server, args ...string) (int, string, t.Helper() t.Setenv("FLATRUN_URL", server.URL) t.Setenv("FLATRUN_TOKEN", "secret") + asTerminal(t) var stdout, stderr bytes.Buffer code := Run(args, &stdout, &stderr) return code, stdout.String(), stderr.String() @@ -353,3 +362,26 @@ func TestUnknownResourceIsStillUnknown(t *testing.T) { t.Fatalf("stderr = %s", stderr) } } + +// A workflow reads stdout, and it read JSON before any of this printed tables. +func TestPipedOutputStaysJSON(t *testing.T) { + for _, command := range [][]string{{"containers", "list"}, {"container", "list"}} { + server, _ := recordingServer(t, `{"containers":[{"id":"c-1","name":"web","state":"running"}]}`) + t.Setenv("FLATRUN_URL", server.URL) + t.Setenv("FLATRUN_TOKEN", "secret") + + previous := stdoutIsTerminal + stdoutIsTerminal = func() bool { return false } + var stdout, stderr bytes.Buffer + code := Run(command, &stdout, &stderr) + stdoutIsTerminal = previous + + if code != 0 { + t.Fatalf("%v: code=%d stderr=%s", command, code, stderr.String()) + } + var decoded map[string]any + if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil { + t.Fatalf("%v: piped output should be JSON, got:\n%s", command, stdout.String()) + } + } +} diff --git a/internal/command/root.go b/internal/command/root.go index ac75577..3d2148a 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -32,6 +32,13 @@ var stdinIsTerminal = func() bool { return err == nil && info.Mode()&os.ModeCharDevice != 0 } +// A table is for someone reading it. Anything else on the other end of the pipe gets JSON, which +// is what it could parse before any of this printed tables. +var stdoutIsTerminal = func() bool { + info, err := os.Stdout.Stat() + return err == nil && info.Mode()&os.ModeCharDevice != 0 +} + var deploymentOperations = map[string]bool{ "restart": true, "rebuild": true, @@ -356,7 +363,7 @@ func runClientCommand(cmd clientCommand, args []string, stdout, stderr io.Writer _, _ = fmt.Fprintln(stderr, "Error:", err) return 1 } - if opts.JSON { + if opts.JSON || !stdoutIsTerminal() { printResponse(stdout, true, data, "") return 0 } diff --git a/internal/command/root_test.go b/internal/command/root_test.go index 15a6bab..f508bd6 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -1082,5 +1082,7 @@ func TestShortDockerTimeParsesDockerAndRFC3339Formats(t *testing.T) { } func TestMain(m *testing.M) { + // These tests read what a person would see on screen, which is when a table is printed. + stdoutIsTerminal = func() bool { return true } os.Exit(m.Run()) } From 792c6b0ec7a81cf1c15be77fdc83f9222585f207 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 15 Aug 2026 11:24:27 +0100 Subject: [PATCH 2/4] fix: Hold back a table on a pipe, not a message Printing the raw answer whenever the output was not a terminal was too broad: it turned a deployment's restart confirmation, and anything else that answers with a sentence, into JSON in a CI log. Only a table is now held back, since something on the other end of a pipe would rather parse the answer than read columns. --- internal/command/endpoints.go | 1 + internal/command/endpoints_test.go | 21 +++++++++++++++++++++ internal/command/root.go | 17 ++++++++++++----- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/internal/command/endpoints.go b/internal/command/endpoints.go index 959c680..aaae4dc 100644 --- a/internal/command/endpoints.go +++ b/internal/command/endpoints.go @@ -206,6 +206,7 @@ func runEndpoint(family string, args []string, stdout, stderr io.Writer) int { } return client.Do(ctx, e.method, path, payload) }, + tabular: true, render: func(w io.Writer, data []byte) error { if renderAnswer(w, api, operation, data) { return nil diff --git a/internal/command/endpoints_test.go b/internal/command/endpoints_test.go index fbc9d73..c9673f6 100644 --- a/internal/command/endpoints_test.go +++ b/internal/command/endpoints_test.go @@ -385,3 +385,24 @@ func TestPipedOutputStaysJSON(t *testing.T) { } } } + +// A message and a command's own output are for whoever asked, terminal or not. Only a table is +// held back, because something on the other end of a pipe would rather parse the answer. +func TestPipedOutputKeepsMessagesReadable(t *testing.T) { + server, _ := recordingServer(t, `{"message":"Deployment restarted","status":"running"}`) + t.Setenv("FLATRUN_URL", server.URL) + t.Setenv("FLATRUN_TOKEN", "secret") + + previous := stdoutIsTerminal + stdoutIsTerminal = func() bool { return false } + var stdout, stderr bytes.Buffer + code := Run([]string{"deployment", "restart", "shop"}, &stdout, &stderr) + stdoutIsTerminal = previous + + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "Deployment restarted") { + t.Fatalf("a message should read as a message when piped, got:\n%s", stdout.String()) + } +} diff --git a/internal/command/root.go b/internal/command/root.go index 3d2148a..c69b3a9 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -65,6 +65,9 @@ type clientCommand struct { flags func(*flag.FlagSet) run func(context.Context, *flatrun.Client, []string) ([]byte, error) render func(io.Writer, []byte) error + // tabular says the rendering is a table, which is worth reading on a terminal and worth + // parsing anywhere else. + tabular bool } type deploymentListItem struct { @@ -363,7 +366,7 @@ func runClientCommand(cmd clientCommand, args []string, stdout, stderr io.Writer _, _ = fmt.Fprintln(stderr, "Error:", err) return 1 } - if opts.JSON || !stdoutIsTerminal() { + if opts.JSON || (cmd.tabular && !stdoutIsTerminal()) { printResponse(stdout, true, data, "") return 0 } @@ -625,7 +628,8 @@ func runDeploymentList(args []string, stdout, stderr io.Writer) int { run: func(ctx context.Context, client *flatrun.Client, args []string) ([]byte, error) { return client.ListDeployments(ctx) }, - render: renderDeploymentList, + render: renderDeploymentList, + tabular: true, }, args, stdout, stderr) } @@ -649,7 +653,8 @@ func runDeploymentActions(args []string, stdout, stderr io.Writer) int { run: func(ctx context.Context, client *flatrun.Client, args []string) ([]byte, error) { return client.GetDeployment(ctx, args[0]) }, - render: renderQuickActions, + render: renderQuickActions, + tabular: true, }, args, stdout, stderr) } @@ -1155,7 +1160,8 @@ func runImageList(args []string, stdout, stderr io.Writer) int { run: func(ctx context.Context, client *flatrun.Client, args []string) ([]byte, error) { return client.ListImages(ctx) }, - render: renderImageList, + render: renderImageList, + tabular: true, }, args, stdout, stderr) } @@ -1219,7 +1225,8 @@ func runContainerList(args []string, stdout, stderr io.Writer) int { run: func(ctx context.Context, client *flatrun.Client, args []string) ([]byte, error) { return client.ListContainers(ctx) }, - render: renderContainerList, + render: renderContainerList, + tabular: true, }, args, stdout, stderr) } From 2242fba8a5fe7ee0de45a05e62b226cd0883c146 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 15 Aug 2026 11:29:47 +0100 Subject: [PATCH 3/4] Revert "fix: Give a pipe the answer, not a table" Holding a table back from a pipe broke the thing it was meant to protect: `deployment list` has printed a table since it existed, so anything grepping one in a workflow would have got JSON instead. No command that shipped ever printed differently depending on where its output went. A table prints wherever it was asked for, and --json is how the raw answer is asked for, which is what every command already did. --- CHANGELOG.md | 2 +- README.md | 4 +- docs/reference/commands.md | 4 +- internal/command/endpoints.go | 1 - internal/command/endpoints_test.go | 65 +++++++++--------------------- internal/command/root.go | 24 +++-------- internal/command/root_test.go | 2 - 7 files changed, 30 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f77c94c..e0978bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to the FlatRun CLI are documented in this file. - 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. -- Output is laid out for whoever is reading it: a table on a terminal, and the raw answer when the output is piped or redirected, which is what a script parsed before. `--json` forces the raw answer either way. +- An answer nothing has described is still laid out: the field holding the rows becomes a table, an array of plain values prints one per line, and an empty one reads `None`. `--json` prints the raw answer, as before. ### Fixed diff --git a/README.md b/README.md index 8f441e1..c7eb8a7 100644 --- a/README.md +++ b/README.md @@ -124,8 +124,8 @@ sends a number. If the agent describes the answer, that decides the layout. Otherwise an array of objects prints as a table, an array of scalars one per line, an empty one as `None`, and anything else as raw JSON. -Piping or redirecting gets the raw answer instead, since something is parsing it. `--json` forces -it either way. +Piping changes nothing, so `flatrun deployment list | grep running` works. `--json` is how you ask +for the raw answer. ### Driving it from a script or an agent diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 9d4745b..b86b9b4 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -180,8 +180,8 @@ maps the JSON itself: | Empty array | `None` | | No array, or more than one | The raw JSON | -None of it applies when the output is not a terminal: a pipe, a redirect or a CI step gets the raw -answer, since something is parsing it. `--json` forces the raw answer either way. +This does not change with where the output goes: a pipe or a CI log gets what the terminal gets, so +`flatrun deployment list | grep running` keeps working. `--json` is how you ask for the raw answer. ## Listing what exists diff --git a/internal/command/endpoints.go b/internal/command/endpoints.go index aaae4dc..959c680 100644 --- a/internal/command/endpoints.go +++ b/internal/command/endpoints.go @@ -206,7 +206,6 @@ func runEndpoint(family string, args []string, stdout, stderr io.Writer) int { } return client.Do(ctx, e.method, path, payload) }, - tabular: true, render: func(w io.Writer, data []byte) error { if renderAnswer(w, api, operation, data) { return nil diff --git a/internal/command/endpoints_test.go b/internal/command/endpoints_test.go index c9673f6..4c2da6b 100644 --- a/internal/command/endpoints_test.go +++ b/internal/command/endpoints_test.go @@ -10,14 +10,6 @@ import ( "testing" ) -// asTerminal runs a command as though someone were watching, which is when a table is wanted. -func asTerminal(t *testing.T) { - t.Helper() - previous := stdoutIsTerminal - stdoutIsTerminal = func() bool { return true } - t.Cleanup(func() { stdoutIsTerminal = previous }) -} - type recordedRequest struct { method string path string @@ -47,7 +39,6 @@ func runCLI(t *testing.T, server *httptest.Server, args ...string) (int, string, t.Helper() t.Setenv("FLATRUN_URL", server.URL) t.Setenv("FLATRUN_TOKEN", "secret") - asTerminal(t) var stdout, stderr bytes.Buffer code := Run(args, &stdout, &stderr) return code, stdout.String(), stderr.String() @@ -363,46 +354,30 @@ func TestUnknownResourceIsStillUnknown(t *testing.T) { } } -// A workflow reads stdout, and it read JSON before any of this printed tables. -func TestPipedOutputStaysJSON(t *testing.T) { - for _, command := range [][]string{{"containers", "list"}, {"container", "list"}} { - server, _ := recordingServer(t, `{"containers":[{"id":"c-1","name":"web","state":"running"}]}`) - t.Setenv("FLATRUN_URL", server.URL) - t.Setenv("FLATRUN_TOKEN", "secret") - - previous := stdoutIsTerminal - stdoutIsTerminal = func() bool { return false } - var stdout, stderr bytes.Buffer - code := Run(command, &stdout, &stderr) - stdoutIsTerminal = previous - - if code != 0 { - t.Fatalf("%v: code=%d stderr=%s", command, code, stderr.String()) - } - var decoded map[string]any - if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil { - t.Fatalf("%v: piped output should be JSON, got:\n%s", command, stdout.String()) - } +// Whatever a command printed before, it prints now: a pipe is not a reason to change the answer, +// and `deployment list | grep running` has to keep working. +func TestOutputDoesNotChangeWhenPiped(t *testing.T) { + server, _ := recordingServer(t, `{"containers":[{"id":"c-1","name":"web","state":"running"}]}`) + code, stdout, stderr := runCLI(t, server, "container", "list") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if !strings.Contains(stdout, "CONTAINER ID") { + t.Fatalf("expected the table, got:\n%s", stdout) + } + if !strings.Contains(stdout, "web") { + t.Fatalf("expected the row, got:\n%s", stdout) } } -// A message and a command's own output are for whoever asked, terminal or not. Only a table is -// held back, because something on the other end of a pipe would rather parse the answer. -func TestPipedOutputKeepsMessagesReadable(t *testing.T) { - server, _ := recordingServer(t, `{"message":"Deployment restarted","status":"running"}`) - t.Setenv("FLATRUN_URL", server.URL) - t.Setenv("FLATRUN_TOKEN", "secret") - - previous := stdoutIsTerminal - stdoutIsTerminal = func() bool { return false } - var stdout, stderr bytes.Buffer - code := Run([]string{"deployment", "restart", "shop"}, &stdout, &stderr) - stdoutIsTerminal = previous - +func TestJSONIsHowYouAskForTheAnswer(t *testing.T) { + server, _ := recordingServer(t, `{"containers":[{"id":"c-1","name":"web","state":"running"}]}`) + code, stdout, stderr := runCLI(t, server, "container", "list", "--json") if code != 0 { - t.Fatalf("code=%d stderr=%s", code, stderr.String()) + t.Fatalf("code=%d stderr=%s", code, stderr) } - if !strings.Contains(stdout.String(), "Deployment restarted") { - t.Fatalf("a message should read as a message when piped, got:\n%s", stdout.String()) + 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) } } diff --git a/internal/command/root.go b/internal/command/root.go index c69b3a9..ac75577 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -32,13 +32,6 @@ var stdinIsTerminal = func() bool { return err == nil && info.Mode()&os.ModeCharDevice != 0 } -// A table is for someone reading it. Anything else on the other end of the pipe gets JSON, which -// is what it could parse before any of this printed tables. -var stdoutIsTerminal = func() bool { - info, err := os.Stdout.Stat() - return err == nil && info.Mode()&os.ModeCharDevice != 0 -} - var deploymentOperations = map[string]bool{ "restart": true, "rebuild": true, @@ -65,9 +58,6 @@ type clientCommand struct { flags func(*flag.FlagSet) run func(context.Context, *flatrun.Client, []string) ([]byte, error) render func(io.Writer, []byte) error - // tabular says the rendering is a table, which is worth reading on a terminal and worth - // parsing anywhere else. - tabular bool } type deploymentListItem struct { @@ -366,7 +356,7 @@ func runClientCommand(cmd clientCommand, args []string, stdout, stderr io.Writer _, _ = fmt.Fprintln(stderr, "Error:", err) return 1 } - if opts.JSON || (cmd.tabular && !stdoutIsTerminal()) { + if opts.JSON { printResponse(stdout, true, data, "") return 0 } @@ -628,8 +618,7 @@ func runDeploymentList(args []string, stdout, stderr io.Writer) int { run: func(ctx context.Context, client *flatrun.Client, args []string) ([]byte, error) { return client.ListDeployments(ctx) }, - render: renderDeploymentList, - tabular: true, + render: renderDeploymentList, }, args, stdout, stderr) } @@ -653,8 +642,7 @@ func runDeploymentActions(args []string, stdout, stderr io.Writer) int { run: func(ctx context.Context, client *flatrun.Client, args []string) ([]byte, error) { return client.GetDeployment(ctx, args[0]) }, - render: renderQuickActions, - tabular: true, + render: renderQuickActions, }, args, stdout, stderr) } @@ -1160,8 +1148,7 @@ func runImageList(args []string, stdout, stderr io.Writer) int { run: func(ctx context.Context, client *flatrun.Client, args []string) ([]byte, error) { return client.ListImages(ctx) }, - render: renderImageList, - tabular: true, + render: renderImageList, }, args, stdout, stderr) } @@ -1225,8 +1212,7 @@ func runContainerList(args []string, stdout, stderr io.Writer) int { run: func(ctx context.Context, client *flatrun.Client, args []string) ([]byte, error) { return client.ListContainers(ctx) }, - render: renderContainerList, - tabular: true, + render: renderContainerList, }, args, stdout, stderr) } diff --git a/internal/command/root_test.go b/internal/command/root_test.go index f508bd6..15a6bab 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -1082,7 +1082,5 @@ func TestShortDockerTimeParsesDockerAndRFC3339Formats(t *testing.T) { } func TestMain(m *testing.M) { - // These tests read what a person would see on screen, which is when a table is printed. - stdoutIsTerminal = func() bool { return true } os.Exit(m.Run()) } From ac02be10f9291c694b8b8fa5e6ac5404ec6572eb Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 15 Aug 2026 11:34:08 +0100 Subject: [PATCH 4/4] test: Read a new agent's answer with the commands that predate it The shared list shape writes the rows under items and under the name the collection used to answer by, so a client built before that shape existed keeps working. This pins it with the bytes the agent actually sends, since a staging agent on an older build cannot show it. --- internal/command/endpoints_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/internal/command/endpoints_test.go b/internal/command/endpoints_test.go index 4c2da6b..a41cce3 100644 --- a/internal/command/endpoints_test.go +++ b/internal/command/endpoints_test.go @@ -381,3 +381,25 @@ func TestJSONIsHowYouAskForTheAnswer(t *testing.T) { t.Fatalf("--json should print the raw answer, got:\n%s", stdout) } } + +// The exact payload a new agent sends for a deployment list: the shared shape, the name it used +// to answer under, and the field it answered alongside. A CLI built before any of that has to +// keep reading it. +const newAgentDeploymentList = `{"deployments":[{"name":"trakli-staging","path":"/opt/flatrun/deployments/trakli-staging","status":"running","created_at":"2026-08-13T22:39:28Z","updated_at":"0001-01-01T00:00:00Z"}],"items":[{"name":"trakli-staging","path":"/opt/flatrun/deployments/trakli-staging","status":"running","created_at":"2026-08-13T22:39:28Z","updated_at":"0001-01-01T00:00:00Z"}],"path":"/opt/flatrun/deployments","total":1}` + +func TestOldCommandsReadANewAgentsAnswer(t *testing.T) { + for _, command := range [][]string{{"deployment", "list"}, {"deployments", "list"}} { + server, _ := recordingServer(t, newAgentDeploymentList) + code, stdout, stderr := runCLI(t, server, command...) + if code != 0 { + t.Fatalf("%v: code=%d stderr=%s", command, code, stderr) + } + if !strings.Contains(stdout, "NAME") || !strings.Contains(stdout, "trakli-staging") { + t.Fatalf("%v: expected the table, got:\n%s", command, stdout) + } + // The rows must come from one of the two names, not from both. + if strings.Count(stdout, "trakli-staging") != 1 { + t.Fatalf("%v: the deployment was listed twice:\n%s", command, stdout) + } + } +}