Commit 0215292cee

0215292cee2c178070124c6c73abf6accee0a210

parent: be3e6f28d7

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-12 06:00 UTC

control: an empty list says so on stderr

Ref #183
.gitbay/wiki/Users.org +2 −2
@@ -667,8 +667,8 @@ terminal, and follows these rules so every noun reads the same way.
667667 the tabs into aligned columns when stdout is a terminal and leaves
668668 them as tabs when piped, so =cut -f= sees the same bytes stock ssh
669669 prints. Under =--json= nothing is touched.
670- An empty list prints nothing on stdout and one line on stderr saying
671 what was empty (=no open issues in krz/gitbay=).
670- An empty list prints nothing on stdout and =nothing to list= on
671 stderr.
672672- A mutation prints one line: verb, object, identifier
673673 (=created krz/gitbay#7=). A second line appears only for something
674674 to copy: a URL, a token shown once.
internal/control/control.go +12 −5
@@ -109,23 +109,23 @@ func Dispatch(c *Ctx, argv []string) int {
109109 // A runner-scoped key reaches the runner protocol and nothing else, so
110110 // the key a CI host holds cannot administer the instance.
111111 if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") {
112 return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope)
112 return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands; use a key added with --scope full", c.Scope)
113113 }
114114 if c.ViaAPI && cmd.SSHOnly {
115115 return c.fail(protocol.ExitDenied, "%s is only available over SSH", joinPath(cmd.Path))
116116 }
117117 if c.ReadOnly && !cmd.ReadOnly {
118 return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state", joinPath(cmd.Path))
118 return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state — mint one with --scope full", joinPath(cmd.Path))
119119 }
120120 // The SSH listener refuses a disabled account before it gets here; the
121121 // API and the web reach Dispatch directly, so the check lives here too.
122122 if c.User.Disabled {
123 return c.fail(protocol.ExitDenied, "this account is disabled")
123 return c.fail(protocol.ExitDenied, "this account is disabled; ask an instance admin to enable it")
124124 }
125125 // The admin noun is gated here as well as in each handler, so a new
126126 // admin command that forgets requireInstanceAdmin is still refused.
127127 if cmd.Path[0] == "admin" && !c.User.IsAdmin {
128 return c.fail(protocol.ExitDenied, "admin commands are for instance admins")
128 return c.fail(protocol.ExitDenied, "admin commands are for instance admins; ask one")
129129 }
130130 if c.User.Pending && !pendingAllowed(cmd.Path) {
131131 return c.fail(protocol.ExitDenied,
@@ -218,9 +218,16 @@ func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF }
218218// otherwise via the plain formatter.
219219func (c *Ctx) emit(data any, plain func(w io.Writer)) int {
220220 // A nil slice would serialize as null; consumers should see [].
221 if v := reflect.ValueOf(data); v.Kind() == reflect.Slice && v.IsNil() {
221 v := reflect.ValueOf(data)
222 if v.Kind() == reflect.Slice && v.IsNil() {
222223 data = reflect.MakeSlice(v.Type(), 0, 0).Interface()
223224 }
225 // An empty list prints nothing a script would read; the person at
226 // the terminal hears about it on stderr.
227 if !c.JSON && v.Kind() == reflect.Slice && v.Len() == 0 {
228 fmt.Fprintln(c.Stderr, "nothing to list")
229 return protocol.ExitOK
230 }
224231 if c.JSON {
225232 enc := json.NewEncoder(c.Stdout)
226233 enc.SetEscapeHTML(false)