Route control-plane success and empty-state output to stdout · Entire
Route control-plane success and empty-state output to stdout
fea978f→main·
peyton-alt·2w ago·10 files·+114 added/-44 removed
cmd.Printf falls back to stderr in production (nothing sets root Out);
adopt the house-style ✓ confirmations via explicit OutOrStdout writers,
replace '(none)'-on-stderr with sentence empty states, and emit [] for
empty --json lists instead of null.
Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Sessions
1bb4a9bc7572View transcript
Changes
10
cmd/entire/cli
Mcorecmd.go+23/-17
Mcorecmd_delete_test.go+13/-11
Acorecmd_list_test.go+58
Mgrant.go+5/-5
Mgrant_wiring_test.go+3/-2
Morg.go+1/-1
Mproject.go+1/-1
Mrepo.go+1/-1
Mrepo_mirror.go+8/-5
Mrepo_mirror_collaborators.go+1/-1
93 unmodified lines
94
95
96
97
97
98
99
100
101
102
102
103
104
105
36 unmodified lines
142
143
144
145
146
147
148
149
150
145
146
147
148
149
150
151
152
153
154
155
154
155
156
157
156
157
158
159
160
161
162
161
162
163
164
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
174
180
181
182
183
93 unmodified lines
// delete call — e.g. a ULID passed straight through, or a concurrent
// delete) is the desired end state, not an error.
if isCoreNotFound(err) {
cmd.Printf("%s not found; nothing to delete\n", label)
fmt.Fprintf(cmd.OutOrStdout(), "%s not found; nothing to delete\n", label)
return nil
}
return err
}
cmd.Printf("Deleted %s\n", label)
fmt.Fprintf(cmd.OutOrStdout(), "✓ Deleted %s\n", label)
return nil
})
}
36 unmodified lines
// runCoreList fetches a slice via fn and renders it as an aligned table
// (default) or the raw wire JSON (--json). headers names the columns; row
// maps one item to its cells in the same order. The human view keeps the
// output actionable — only the columns a person acts on — while --json
// preserves the full model for scripting.
func runCoreList[T any](cmd *cobra.Command, headers []string, row func(T) []string, fn func(ctx context.Context, c *coreapi.Client) ([]T, error)) error {
return runCore(cmd, renderCoreList(cmd, headers, row, fn))
// (default) or the raw wire JSON (--json). empty is the full sentence printed
// to stdout in place of the table when there are no items (e.g. "No
// organizations found."). headers names the columns; row maps one item to its
// cells in the same order. The human view keeps the output actionable — only
// the columns a person acts on — while --json preserves the full model for
// scripting.
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 {
return runCore(cmd, renderCoreList(cmd, empty, headers, row, fn))
}
// runCoreListForCluster is runCoreList for a resource-provider command (see
// runCoreForCluster): identical table/JSON rendering, but dialing the core that
// fronts clusterHost rather than the active context.
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 {
return runCoreForCluster(cmd, clusterHost, renderCoreList(cmd, headers, row, fn))
// runCoreForCluster): identical table/JSON/empty-state rendering, but dialing
// the core that fronts clusterHost rather than the active context.
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 {
return runCoreForCluster(cmd, clusterHost, renderCoreList(cmd, empty, headers, row, fn))
}
// renderCoreList builds the run-function shared by runCoreList and
// runCoreListForCluster: fetch via fn, then render as a table (default) or raw
// JSON (--json). Kept separate from the client-selection so the two list
// variants differ only in which core they dial.
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 {
// runCoreListForCluster: fetch via fn, then render as a table (default), the
// empty sentence (no items), or raw JSON (--json). Kept separate from the
// client-selection so the two list variants differ only in which core they
// dial.
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 {
return func(ctx context.Context, c *coreapi.Client) error {
items, err := fn(ctx, c)
if err != nil {
return err
}
if jsonRequested(cmd) {
if items == nil {
items = []T{} // a nil slice encodes as null; scripts expect []
}
return printJSON(cmd.OutOrStdout(), items)
}
if len(items) == 0 {
fmt.Fprintln(cmd.ErrOrStderr(), "(none)")
fmt.Fprintln(cmd.OutOrStdout(), empty)
return nil
}
return printTable(cmd.OutOrStdout(), headers, items, row)
}
Mcmd/entire/cli/corecmd.go+23/-17
32 unmodified lines
33 34 35 36 37 38 36 37 38 39 40 41 2 unmodified lines
44 45 46 47 47 48 49 49 50 51 52 51 52 53 54 55 23 unmodified lines
79 80 81 82 82 83 84 85 86 86 87 88 89 90 2 unmodified lines
93 94 95 95 96 97 98 99 100 101 102 3 unmodified lines
106 107 108 107 109 110 111 112
32 unmodified lines
// runDeleteCmd points the active-context client at srv via the activeCoreClient // seam, runs newCmd() with args, and returns its stdout and error. The caller // must not be parallel: the seam is package-global. func runDeleteCmd(t *testing.T, newCmd func() *cobra.Command, srvURL string, args ...string) (string, error) { // seam, runs newCmd() with args, and returns its stdout, stderr, and error. The // caller must not be parallel: the seam is package-global. func runDeleteCmd(t *testing.T, newCmd func() *cobra.Command, srvURL string, args ...string) (stdout, stderr string, err error) { t.Helper() prev := activeCoreClient activeCoreClient = func(context.Context) (*coreapi.Client, error) {
}
t.Cleanup(func() { activeCoreClient = prev })
cmd := newCmd() var out bytes.Buffer var out, errW bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&bytes.Buffer{}) cmd.SetErr(&errW) cmd.SetArgs(args) err := cmd.ExecuteContext(t.Context()) return out.String(), err err = cmd.ExecuteContext(t.Context()) return out.String(), errW.String(), err }
// TestControlPlaneDelete_Wiring exercises the org/project/repo delete commands 23 unmodified lines
})) t.Cleanup(srv.Close)
out, err := runDeleteCmd(t, tc.newCmd, srv.URL, testDeleteULID, "--force") out, errOut, err := runDeleteCmd(t, tc.newCmd, srv.URL, testDeleteULID, "--force") require.NoError(t, err) require.Equal(t, http.MethodDelete, gotMethod) require.Equal(t, tc.wantPath, gotPath) require.Contains(t, out, "Deleted "+tc.noun+" "+testDeleteULID) require.Contains(t, out, "✓ Deleted "+tc.noun+" "+testDeleteULID) require.Empty(t, errOut, "success output must go to stdout, not stderr") })
t.Run(tc.noun+"/already-gone is idempotent", func(t *testing.T) {
})
})
t.Cleanup(srv.Close)
out, err := runDeleteCmd(t, tc.newCmd, srv.URL, testDeleteULID, "--force") out, errOut, err := runDeleteCmd(t, tc.newCmd, srv.URL, testDeleteULID, "--force") require.NoError(t, err) require.Contains(t, out, "not found; nothing to delete") require.Empty(t, errOut) })
t.Run(tc.noun+"/refuses without --force when non-interactive", func(t *testing.T) {
})
Mcmd/entire/cli/corecmd_delete_test.go+13/-11
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
package cli
import ( "bytes" "context" "testing"
"github.com/spf13/cobra" "github.com/stretchr/testify/require"
"github.com/entireio/cli/internal/coreapi" )
// listCmd builds a minimal command wired to renderCoreList via runCoreList so // the rendering path (table / empty state / --json) is exercised without a // server: fn returns items directly. func runListRender(t *testing.T, jsonFlag bool, items []coreapi.Org) (stdout, stderr string) { t.Helper() prev := activeCoreClient activeCoreClient = func(context.Context) (*coreapi.Client, error) { // The URL is never contacted: fn below returns items directly, so // the client only needs to construct. return coreapi.NewWithBearer("http://127.0.0.1:0", "tok") } t.Cleanup(func() { activeCoreClient = prev })
cmd := &cobra.Command{ Use: "list", RunE: func(cmd *cobra.Command, _ []string) error { return runCoreList(cmd, "No organizations found.", orgColumns, orgRow, func (context.Context, *coreapi.Client) ([]coreapi.Org, error) { return items, nil }) }, } cmd.Flags().Bool("json", false, "Output raw JSON instead of a table") var out, errW bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&errW) args := []string{} if jsonFlag { args = append(args, "--json") } cmd.SetArgs(args) require.NoError(t, cmd.ExecuteContext(t.Context())) return out.String(), errW.String() }
// Not parallel: swaps the package-level activeCoreClient seam. func TestRunCoreList_EmptyHumanMessageOnStdout(t *testing.T) { out, errOut := runListRender(t, false, nil) require.Contains(t, out, "No organizations found.") require.Empty(t, errOut, "empty-state message must go to stdout") }
// Not parallel: swaps the package-level activeCoreClient seam. func TestRunCoreList_EmptyJSONIsArray(t *testing.T) { out, _ := runListRender(t, true, nil) require.JSONEq(t, "[]", out, "empty --json list must be [], not null") }
Mcmd/entire/cli/corecmd_list_test.go+58
150 unmodified lines
151 152 153 154 154 155 156 157 99 unmodified lines
257 258 259 260 260 261 262 263 139 unmodified lines
403 404 405 406 406 407 408 409 68 unmodified lines
478 479 480 481 481 482 483 484 485 486 486 487 488
150 unmodified lines
Short: "List org members", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runCoreList(cmd, orgMemberColumns, orgMemberRow, func(ctx context.Context, c *coreapi.Client) ([]coreapi.Membership, error) { return runCoreList(cmd, "No members found.", orgMemberColumns, orgMemberRow, func(ctx context.Context, c *coreapi.Client) ([]coreapi.Membership, error) { orgID, err := resolveOrgRef(ctx, c, args[0]) if err != nil { return nil, err } return nil, err
}, }) } } }
Short: "List project members", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runCoreList(cmd, grantColumns, projectGrantRow, func(ctx context.Context, c *coreapi.Client) ([]coreapi.ProjectGrant, error) { return runCoreList(cmd, "No grants found.", grantColumns, projectGrantRow, func(ctx context.Context, c *coreapi.Client) ([]coreapi.ProjectGrant, error) { projID, err := resolveProjectRef(ctx, c, args[0]) if err != nil { return nil, err } return nil, err
}, }) } } }
Short: "List repo grants", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runCoreList(cmd, grantColumns, repoGrantRow, func(ctx context.Context, c *coreapi.Client) ([]coreapi.RepoGrant, error) { return runCoreList(cmd, "No grants found.", grantColumns, repoGrantRow, func(ctx context.Context, c *coreapi.Client) ([]coreapi.RepoGrant, error) { repoID, err := resolveRepoRef(ctx, c, args[0], project) if err != nil { return nil, err } return nil, err
}, }) } } }
Mcmd/entire/cli/corecmd_list_test.go+58
150 unmodified lines
151 152 153 154 154 155 156 157 99 unmodified lines
257 258 259 260 260 261 262 263 139 unmodified lines
403 404 405 406 406 407 408 409 68 unmodified lines
478 479 480 481 481 482 483 484 485 486 486 487 488
150 unmodified lines
}, }) } } }
}, }) } } }
}, }) } } }
Mcmd/entire/cli/corecmd_list_test.go+58
150 unmodified lines
151 152 153 154 154 155 156 157 99 unmodified lines
257 258 259 260 260 261 262 263 139 unmodified lines
403 404 405 406 406 407 408 409 68 unmodified lines
478 479 480 481 481 482 483 484 485 486 486 487 488
150 unmodified lines
}, }) } } }
}, }) } } }
}, }) } } }
Mcmd/entire/cli/corecmd_list_test.go+58
150 unmodified lines
151 152 153 154 154 155 156 157 99 unmodified lines
257 258 259 260 260 261 262 263 139 unmodified lines
403 404 405 406 406 407 408 409 68 unmodified lines
478 479 480 481 481 482 483 484 485 486 486 487 488
150 unmodified lines
}, }) } } }
}, }) } } }
}, }) } } }
Mcmd/entire/cli/corecmd_list_test.go+58
150 unmodified lines