Skip to content

Commit 21660d5

Browse files
authored
Merge pull request #1626 from entireio/chore/control-plane-output-cleanup
Standardize control-plane command output to CLI house style
2 parents 4398a44 + 21e2d8d commit 21660d5

17 files changed

Lines changed: 447 additions & 139 deletions

.golangci.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ linters:
107107
- pattern: '^.*\.Checkout$'
108108
msg: "go-git Checkout deletes .gitignored dirs - use CheckoutBranch() from git_operations.go"
109109
pkg: 'github\.com/go-git/go-git'
110+
- pattern: '^.*\.(Print|Println|Printf)$'
111+
msg: "cobra's Print* writes to OutOrStderr (stderr in production); use fmt.Fprint*(cmd.OutOrStdout(), ...)"
112+
pkg: 'github\.com/spf13/cobra'
110113
govet:
111114
enable-all: true
112115
disable:

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/),
66
and this project adheres to [Semantic Versioning](https://semver.org/).
77

8+
## [Unreleased]
9+
10+
### Changed
11+
12+
- Control-plane commands (`org`, `project`, `repo`, `grant`) now print human-readable confirmations by default; the wire JSON (including `repo create`'s `entire://` remote) moved behind `--json`. Empty `--json` lists emit `[]`, and success messages moved from stderr to stdout ([#1626](https://github.com/entireio/cli/pull/1626))
13+
814
## [0.7.8] - 2026-06-30
915

1016
### Added

cmd/entire/cli/corecmd.go

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import (
2525
// (local/dev deployments where the core isn't behind TLS). Hidden, as
2626
// elsewhere in the CLI.
2727
func addControlPlaneFlags(cmd *cobra.Command) {
28-
cmd.PersistentFlags().Bool("json", false, "output raw JSON instead of a table")
28+
cmd.PersistentFlags().Bool("json", false, "Output raw JSON instead of a table")
2929
cmd.PersistentFlags().Bool("insecure-http-auth", false, "Allow authentication over plain HTTP (insecure, for local development only)")
3030
if err := cmd.PersistentFlags().MarkHidden("insecure-http-auth"); err != nil {
3131
panic(fmt.Sprintf("hide insecure-http-auth flag: %v", err))
@@ -94,12 +94,12 @@ func runControlPlaneDelete(
9494
// delete call — e.g. a ULID passed straight through, or a concurrent
9595
// delete) is the desired end state, not an error.
9696
if isCoreNotFound(err) {
97-
cmd.Printf("%s not found; nothing to delete\n", label)
97+
fmt.Fprintf(cmd.OutOrStdout(), "%s not found; nothing to delete\n", label)
9898
return nil
9999
}
100100
return err
101101
}
102-
cmd.Printf("Deleted %s\n", label)
102+
fmt.Fprintf(cmd.OutOrStdout(), "✓ Deleted %s\n", label)
103103
return nil
104104
})
105105
}
@@ -142,36 +142,42 @@ func confirmControlPlaneDeletion(ctx context.Context, w io.Writer, label string,
142142
}
143143

144144
// runCoreList fetches a slice via fn and renders it as an aligned table
145-
// (default) or the raw wire JSON (--json). headers names the columns; row
146-
// maps one item to its cells in the same order. The human view keeps the
147-
// output actionable — only the columns a person acts on — while --json
148-
// preserves the full model for scripting.
149-
func runCoreList[T any](cmd *cobra.Command, headers []string, row func(T) []string, fn func(ctx context.Context, c *coreapi.Client) ([]T, error)) error {
150-
return runCore(cmd, renderCoreList(cmd, headers, row, fn))
145+
// (default) or the raw wire JSON (--json). empty is the full sentence printed
146+
// to stdout in place of the table when there are no items (e.g. "No
147+
// organizations found."). headers names the columns; row maps one item to its
148+
// cells in the same order. The human view keeps the output actionable — only
149+
// the columns a person acts on — while --json preserves the full model for
150+
// scripting.
151+
func runCoreList[T any](cmd *cobra.Command, empty string, headers []string, row func(T) []string, fn func(ctx context.Context, c *coreapi.Client) ([]T, error)) error {
152+
return runCore(cmd, renderCoreList(cmd, empty, headers, row, fn))
151153
}
152154

153155
// runCoreListForCluster is runCoreList for a resource-provider command (see
154-
// runCoreForCluster): identical table/JSON rendering, but dialing the core that
155-
// fronts clusterHost rather than the active context.
156-
func runCoreListForCluster[T any](cmd *cobra.Command, clusterHost string, headers []string, row func(T) []string, fn func(ctx context.Context, c *coreapi.Client) ([]T, error)) error {
157-
return runCoreForCluster(cmd, clusterHost, renderCoreList(cmd, headers, row, fn))
156+
// runCoreForCluster): identical table/JSON/empty-state rendering, but dialing
157+
// the core that fronts clusterHost rather than the active context.
158+
func runCoreListForCluster[T any](cmd *cobra.Command, clusterHost, empty string, headers []string, row func(T) []string, fn func(ctx context.Context, c *coreapi.Client) ([]T, error)) error {
159+
return runCoreForCluster(cmd, clusterHost, renderCoreList(cmd, empty, headers, row, fn))
158160
}
159161

160162
// renderCoreList builds the run-function shared by runCoreList and
161-
// runCoreListForCluster: fetch via fn, then render as a table (default) or raw
162-
// JSON (--json). Kept separate from the client-selection so the two list
163-
// variants differ only in which core they dial.
164-
func renderCoreList[T any](cmd *cobra.Command, headers []string, row func(T) []string, fn func(ctx context.Context, c *coreapi.Client) ([]T, error)) func(context.Context, *coreapi.Client) error {
163+
// runCoreListForCluster: fetch via fn, then render as a table (default), the
164+
// empty sentence (no items), or raw JSON (--json). Kept separate from the
165+
// client-selection so the two list variants differ only in which core they
166+
// dial.
167+
func renderCoreList[T any](cmd *cobra.Command, empty string, headers []string, row func(T) []string, fn func(ctx context.Context, c *coreapi.Client) ([]T, error)) func(context.Context, *coreapi.Client) error {
165168
return func(ctx context.Context, c *coreapi.Client) error {
166169
items, err := fn(ctx, c)
167170
if err != nil {
168171
return err
169172
}
170173
if jsonRequested(cmd) {
174+
if items == nil {
175+
items = []T{} // a nil slice encodes as null; scripts expect []
176+
}
171177
return printJSON(cmd.OutOrStdout(), items)
172178
}
173179
if len(items) == 0 {
174-
fmt.Fprintln(cmd.ErrOrStderr(), "(none)")
180+
fmt.Fprintln(cmd.OutOrStdout(), empty)
175181
return nil
176182
}
177183
return printTable(cmd.OutOrStdout(), headers, items, row)
@@ -348,19 +354,26 @@ func writeTableRow(b *strings.Builder, cells []string, widths []int, styleFor fu
348354
b.WriteByte('\n')
349355
}
350356

351-
// runCoreJSON runs fn against an authenticated control-plane client and
352-
// prints its result as indented JSON. It owns the preamble every
353-
// control-plane command shares: silence usage so input errors don't spam
354-
// the usage block, build the client, and map an API error to a
355-
// problem-detail SilentError. Commands supply only the call + the value to
356-
// render.
357-
func runCoreJSON(cmd *cobra.Command, fn func(ctx context.Context, c *coreapi.Client) (any, error)) error {
357+
// runCoreMutation runs fn against the control plane and renders its outcome
358+
// the way the rest of the CLI renders mutations: prints the caller's
359+
// ✓-prefixed confirmation on stdout by default, or the wire object as JSON
360+
// when --json was passed. fn
361+
// returns both so the human line can name the created resource while --json
362+
// preserves the full wire model (additive-only: synthesized fields like the
363+
// repo remote URL are merged in, nothing is ever omitted). It owns the same
364+
// preamble as the other runCore variants: silence usage, build the client,
365+
// map API errors to problem-detail messages.
366+
func runCoreMutation(cmd *cobra.Command, fn func(ctx context.Context, c *coreapi.Client) (message string, wire any, err error)) error {
358367
return runCore(cmd, func(ctx context.Context, c *coreapi.Client) error {
359-
out, err := fn(ctx, c)
368+
message, wire, err := fn(ctx, c)
360369
if err != nil {
361370
return err
362371
}
363-
return printJSON(cmd.OutOrStdout(), out)
372+
if jsonRequested(cmd) {
373+
return printJSON(cmd.OutOrStdout(), wire)
374+
}
375+
fmt.Fprintln(cmd.OutOrStdout(), message)
376+
return nil
364377
})
365378
}
366379

@@ -370,11 +383,13 @@ func runCoreJSON(cmd *cobra.Command, fn func(ctx context.Context, c *coreapi.Cli
370383
// without standing up the auth/context/TLS stack.
371384
var activeCoreClient = func(context.Context) (*coreapi.Client, error) { return coreapi.New() }
372385

373-
// runCore is the variant for commands that don't render JSON (delete,
374-
// revoke, remove): it runs the same preamble — silence usage, build
375-
// client, map API errors — and leaves any success output to fn. The client
376-
// dials the active context's core (coreapi.New); use runCoreForCluster for
377-
// commands addressed at a specific cluster.
386+
// runCore is the shared base for every active-context control-plane command:
387+
// it owns the preamble only — silence usage, build the client, map API
388+
// errors — and leaves all rendering to fn. The delete/revoke verbs call it
389+
// directly and render their own output; runCoreList, runCoreObject, and
390+
// runCoreMutation build on it to add their table/JSON/confirmation
391+
// rendering. The client dials the active context's core (coreapi.New); use
392+
// runCoreForCluster for commands addressed at a specific cluster.
378393
func runCore(cmd *cobra.Command, fn func(ctx context.Context, c *coreapi.Client) error) error {
379394
return runCoreClient(cmd, activeCoreClient, fn)
380395
}
@@ -446,7 +461,7 @@ func renderCoreError(err error) error {
446461
}
447462

448463
// printJSON writes v as indented JSON to w — the --json view for list/get
449-
// and the default for create commands that echo the new object.
464+
// and mutations.
450465
func printJSON(w io.Writer, v any) error {
451466
enc := json.NewEncoder(w)
452467
enc.SetIndent("", " ")

cmd/entire/cli/corecmd_delete_test.go

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,18 @@ func writeNotFoundProblem(t *testing.T, w http.ResponseWriter) {
3232
}
3333
}
3434

35-
// runDeleteCmd points the active-context client at srv via the activeCoreClient
36-
// seam, runs newCmd() with args, and returns its stdout and error. The caller
37-
// must not be parallel: the seam is package-global.
38-
func runDeleteCmd(t *testing.T, newCmd func() *cobra.Command, srvURL string, args ...string) (string, error) {
35+
// runCoreCmd runs any active-context control-plane command against a seamed
36+
// httptest core: it points the active-context client at srv via the
37+
// activeCoreClient seam, runs newCmd() with args, and returns its stdout,
38+
// stderr, and error. Commands dialing via runCoreForCluster (mirror
39+
// create/remove/collaborators) bypass the seam and need their own httptest
40+
// wiring. The caller must not be parallel: the seam is package-global.
41+
//
42+
// Note: cobra's cmd.Print* falls back to OutOrStderr(), which under SetOut
43+
// resolves to the stdout buffer — so Empty(errOut) assertions in these tests
44+
// only guard explicit ErrOrStderr writes; the Contains-on-stdout assertions
45+
// are what pin the production stream.
46+
func runCoreCmd(t *testing.T, newCmd func() *cobra.Command, srvURL string, args ...string) (stdout, stderr string, err error) {
3947
t.Helper()
4048
prev := activeCoreClient
4149
activeCoreClient = func(context.Context) (*coreapi.Client, error) {
@@ -44,12 +52,12 @@ func runDeleteCmd(t *testing.T, newCmd func() *cobra.Command, srvURL string, arg
4452
t.Cleanup(func() { activeCoreClient = prev })
4553

4654
cmd := newCmd()
47-
var out bytes.Buffer
55+
var out, errW bytes.Buffer
4856
cmd.SetOut(&out)
49-
cmd.SetErr(&bytes.Buffer{})
57+
cmd.SetErr(&errW)
5058
cmd.SetArgs(args)
51-
err := cmd.ExecuteContext(t.Context())
52-
return out.String(), err
59+
err = cmd.ExecuteContext(t.Context())
60+
return out.String(), errW.String(), err
5361
}
5462

5563
// TestControlPlaneDelete_Wiring exercises the org/project/repo delete commands
@@ -79,11 +87,12 @@ func TestControlPlaneDelete_Wiring(t *testing.T) {
7987
}))
8088
t.Cleanup(srv.Close)
8189

82-
out, err := runDeleteCmd(t, tc.newCmd, srv.URL, testDeleteULID, "--force")
90+
out, errOut, err := runCoreCmd(t, tc.newCmd, srv.URL, testDeleteULID, "--force")
8391
require.NoError(t, err)
8492
require.Equal(t, http.MethodDelete, gotMethod)
8593
require.Equal(t, tc.wantPath, gotPath)
86-
require.Contains(t, out, "Deleted "+tc.noun+" "+testDeleteULID)
94+
require.Contains(t, out, "✓ Deleted "+tc.noun+" "+testDeleteULID)
95+
require.Empty(t, errOut, "no explicit ErrOrStderr writes expected")
8796
})
8897

8998
t.Run(tc.noun+"/already-gone is idempotent", func(t *testing.T) {
@@ -92,9 +101,10 @@ func TestControlPlaneDelete_Wiring(t *testing.T) {
92101
}))
93102
t.Cleanup(srv.Close)
94103

95-
out, err := runDeleteCmd(t, tc.newCmd, srv.URL, testDeleteULID, "--force")
104+
out, errOut, err := runCoreCmd(t, tc.newCmd, srv.URL, testDeleteULID, "--force")
96105
require.NoError(t, err)
97106
require.Contains(t, out, "not found; nothing to delete")
107+
require.Empty(t, errOut)
98108
})
99109

100110
t.Run(tc.noun+"/refuses without --force when non-interactive", func(t *testing.T) {
@@ -104,7 +114,7 @@ func TestControlPlaneDelete_Wiring(t *testing.T) {
104114
}))
105115
t.Cleanup(srv.Close)
106116

107-
_, err := runDeleteCmd(t, tc.newCmd, srv.URL, testDeleteULID)
117+
_, _, err := runCoreCmd(t, tc.newCmd, srv.URL, testDeleteULID)
108118
require.Error(t, err)
109119
require.Contains(t, err.Error(), "--force")
110120
})
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package cli
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/entireio/cli/internal/coreapi"
12+
)
13+
14+
// serveOrgList answers GET /api/v1/orgs with the given orgs, standing in for
15+
// the control plane behind `entire org list`.
16+
func serveOrgList(t *testing.T, orgs []coreapi.Org) *httptest.Server {
17+
t.Helper()
18+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+
assert.Equal(t, http.MethodGet, r.Method)
20+
w.Header().Set("Content-Type", "application/json")
21+
if err := writeJSON(w, &coreapi.ListOrgsOutputBody{Orgs: orgs}); err != nil {
22+
t.Errorf("encode orgs: %v", err)
23+
}
24+
}))
25+
t.Cleanup(srv.Close)
26+
return srv
27+
}
28+
29+
// Not parallel: runCoreCmd swaps the package-level activeCoreClient seam.
30+
func TestRunCoreList_EmptyHumanMessageOnStdout(t *testing.T) {
31+
srv := serveOrgList(t, nil)
32+
out, errOut, err := runCoreCmd(t, newOrgListCmd, srv.URL)
33+
require.NoError(t, err)
34+
require.Contains(t, out, "No organizations found.")
35+
require.Empty(t, errOut, "empty-state message must go to stdout")
36+
}
37+
38+
// Not parallel: runCoreCmd swaps the package-level activeCoreClient seam.
39+
func TestRunCoreList_EmptyJSONIsArray(t *testing.T) {
40+
srv := serveOrgList(t, nil)
41+
// org list's --json is persistent on the group root, so drive the full
42+
// group command with "list" as a subcommand arg.
43+
out, _, err := runCoreCmd(t, newOrgCmd, srv.URL, "list", "--json")
44+
require.NoError(t, err)
45+
require.JSONEq(t, "[]", out, "empty --json list must be [], not null")
46+
}
47+
48+
// Not parallel: runCoreCmd swaps the package-level activeCoreClient seam.
49+
func TestRunCoreList_RendersRows(t *testing.T) {
50+
srv := serveOrgList(t, []coreapi.Org{{ID: testDeleteULID, Name: "acme", Region: "us"}})
51+
out, errOut, err := runCoreCmd(t, newOrgListCmd, srv.URL)
52+
require.NoError(t, err)
53+
require.Contains(t, out, "NAME")
54+
require.Contains(t, out, "acme")
55+
require.Empty(t, errOut)
56+
}

0 commit comments

Comments
 (0)