CLI output: terminal tables !468

merged merged by cmc on 2026-09-24 15:08 UTC · krz/gitbay:cli-output-tables into main

47 files changed, +1312 −184

Layout: unified · split

cmd/gitbay/main.go +1
@@ -17,6 +17,7 @@ import (
1717)
1818
1919func main() {
20 os.Args, noColor = stripNoColor(os.Args)
2021 if err := newRoot().Execute(); err != nil {
2122 fmt.Fprintln(os.Stderr, "gitbay:", err)
2223 os.Exit(protocol.ExitUsage)
cmd/gitbay/ssh.go +43 −41
@@ -1,8 +1,6 @@
11package main
22
33import (
4 "text/tabwriter"
5
64 "encoding/json"
75 "fmt"
86 "golang.org/x/term"
@@ -11,6 +9,7 @@ import (
119 "os/exec"
1210 "path/filepath"
1311 "regexp"
12 "slices"
1413 "strconv"
1514 "strings"
1615
@@ -109,10 +108,52 @@ func sshArgs(inst cliconfig.Instance) []string {
109108 return append(args, inst.SSHOptions...)
110109}
111110
111// noColor is --no-color, stripped from argv in main.
112var noColor bool
113
114// termValue is GITBAY_TERM for this invocation: the terminal's width,
115// and whether colour is wanted. Empty when stdout is not a terminal,
116// so piped output stays the rows stock ssh prints.
117func termValue(isTerminal bool, cols int, env func(string) string) string {
118 if !isTerminal || cols < 40 {
119 return ""
120 }
121 v := strconv.Itoa(cols)
122 if !noColor && env("NO_COLOR") == "" && env("TERM") != "dumb" {
123 v += ",color"
124 }
125 return v
126}
127
128// stripNoColor removes --no-color wherever it appears.
129func stripNoColor(args []string) ([]string, bool) {
130 out := args[:0:0]
131 found := false
132 for _, a := range args {
133 if a == "--no-color" {
134 found = true
135 continue
136 }
137 out = append(out, a)
138 }
139 return out, found
140}
141
112142// runSSH executes the server command over the system ssh binary, wiring
113143// stdio through. It returns the remote exit code.
114144func runSSH(t target, serverArgv []string, stdin io.Reader) int {
115145 args := sshArgs(t.inst)
146
147 fd := int(os.Stdout.Fd())
148 cols := 0
149 isTTY := term.IsTerminal(fd)
150 if isTTY {
151 cols, _, _ = term.GetSize(fd)
152 }
153 if v := termValue(isTTY, cols, os.Getenv); v != "" && !slices.Contains(serverArgv, "--json") {
154 serverArgv = append([]string{"--term=" + v}, serverArgv...)
155 }
156
116157 quoted := make([]string, len(serverArgv))
117158 for i, a := range serverArgv {
118159 quoted[i] = shellQuote(a)
@@ -123,15 +164,7 @@ func runSSH(t target, serverArgv []string, stdin io.Reader) int {
123164 cmd.Stdin = stdin
124165 cmd.Stdout = os.Stdout
125166 cmd.Stderr = os.Stderr
126 var tw *tabwriter.Writer
127 if alignColumns(serverArgv) {
128 tw = tabwriter.NewWriter(os.Stdout, 0, 8, 2, ' ', 0)
129 cmd.Stdout = tw
130 }
131167 err := cmd.Run()
132 if tw != nil {
133 tw.Flush()
134 }
135168 if err == nil {
136169 return 0
137170 }
@@ -204,34 +237,3 @@ func withRepo(t target, args []string) ([]string, error) {
204237 }
205238 return append([]string{t.repo}, args...), nil
206239}
207
208// listVerbs are the server commands whose plain output is one row per
209// item with tab-separated columns.
210var listVerbs = map[string]bool{
211 "list": true, "runners": true, "deliveries": true, "refs": true,
212 "revisions": true, "threads": true, "bookmarks": true, "jobs": true,
213 "prune": true,
214}
215
216// alignColumns reports whether a command's rows should be padded into
217// columns: a list command, printed for a person at a terminal. Piped
218// output keeps the server's tabs so cut and awk see the same bytes stock
219// ssh prints, and --json is never touched.
220func alignColumns(serverArgv []string) bool {
221 verb := ""
222 for _, a := range serverArgv {
223 if strings.HasPrefix(a, "-") {
224 break
225 }
226 if a == "--json" {
227 return false
228 }
229 verb = a
230 }
231 for _, a := range serverArgv {
232 if a == "--json" {
233 return false
234 }
235 }
236 return listVerbs[verb] && term.IsTerminal(int(os.Stdout.Fd()))
237}
cmd/gitbay/term_test.go added +37
@@ -0,0 +1,37 @@
1package main
2
3import "testing"
4
5func TestTermValue(t *testing.T) {
6 env := func(m map[string]string) func(string) string {
7 return func(k string) string { return m[k] }
8 }
9 cases := []struct {
10 tty bool
11 cols int
12 env map[string]string
13 noColor bool
14 want string
15 }{
16 {true, 120, nil, false, "120,color"},
17 {false, 120, nil, false, ""},
18 {true, 30, nil, false, ""},
19 {true, 120, map[string]string{"NO_COLOR": "1"}, false, "120"},
20 {true, 120, map[string]string{"TERM": "dumb"}, false, "120"},
21 {true, 120, nil, true, "120"},
22 }
23 for _, c := range cases {
24 noColor = c.noColor
25 if got := termValue(c.tty, c.cols, env(c.env)); got != c.want {
26 t.Errorf("%+v: got %q", c, got)
27 }
28 }
29 noColor = false
30}
31
32func TestStripNoColor(t *testing.T) {
33 args, ok := stripNoColor([]string{"gitbay", "issue", "list", "--no-color", "--state", "all"})
34 if !ok || len(args) != 5 || args[3] != "--state" {
35 t.Errorf("got %v %v", args, ok)
36 }
37}
cmd/gitbayd/system.go +2 −1
@@ -8,6 +8,7 @@ import (
88 "golang.org/x/crypto/ssh"
99
1010 "gitbay.org/gitbay/internal/config"
11 "gitbay.org/gitbay/internal/control"
1112 "gitbay.org/gitbay/internal/protocol"
1213 "gitbay.org/gitbay/internal/sshd"
1314)
@@ -93,7 +94,7 @@ func shellCmd() *cobra.Command {
9394 fmt.Fprintf(os.Stderr, "gitbay control plane: interactive shells are not available.\nTry: ssh <host> help\n")
9495 os.Exit(protocol.ExitUsage)
9596 }
96 code := sshd.Exec(cfg, st, user, key.Scope, key.Fingerprint, cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil)
97 code := sshd.Exec(cfg, st, user, key.Scope, key.Fingerprint, control.ParseTerm(os.Getenv("GITBAY_TERM")), cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil)
9798 st.Close()
9899 os.Exit(code)
99100 return nil
e2e/readonly_test.go +55
@@ -10,7 +10,9 @@ import (
1010 "regexp"
1111 "strings"
1212 "testing"
13 "unicode"
1314
15 "golang.org/x/text/width"
1416 _ "modernc.org/sqlite"
1517
1618 "gitbay.org/gitbay/internal/control"
@@ -168,6 +170,20 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
168170 }
169171 // Reads whose subject legitimately does not exist in this fixture.
170172 notFoundOK := map[string]bool{"wiki show": true, "repo deps status": true}
173 // rawOutput prints content verbatim (a file, a log, a diff) and is
174 // not fitted to the terminal.
175 rawOutput := map[string]bool{
176 "repo download": true,
177 "account export": true,
178 "admin user show": true, // until the view layout (Part 3)
179 "admin runners": true, // until the view layout (Part 3): the queue summary line
180 "admin stats": true, // until the view layout (Part 3)
181 "repo deps status": true, // until the view layout (Part 3)
182 "release show": true, // until the view layout (Part 3)
183 }
184 // binaryOutput's bytes are not text: a stray 0x1b is coincidence, not
185 // an SGR sequence escaping into plain output.
186 binaryOutput := map[string]bool{"repo download": true}
171187
172188 dbPath := filepath.Join(inst.root, "gitbay.db")
173189 before := dbFingerprint(t, dbPath)
@@ -197,7 +213,46 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
197213 }
198214 }
199215 before = after
216
217 argv := append(append([]string{}, cmd.Path...), args...)
218 plainOut, plainErrOut, plainCode := inst.sshTerm(t, aliceKey, "", argv...)
219 if plainCode != 0 && !(plainCode == 3 && notFoundOK[path]) {
220 t.Errorf("%s: --term= plain exit %d: %s", path, plainCode, strings.TrimSpace(plainErrOut))
221 }
222 if !binaryOutput[path] && strings.Contains(plainOut, "\x1b") {
223 t.Errorf("%s: SGR bytes in plain output", path)
224 }
225 termOut, termErrOut, termCode := inst.sshTerm(t, aliceKey, "60,color", argv...)
226 if termCode != 0 && !(termCode == 3 && notFoundOK[path]) {
227 t.Errorf("%s: --term=60,color exit %d: %s", path, termCode, strings.TrimSpace(termErrOut))
228 }
229 if !rawOutput[path] {
230 for _, line := range strings.Split(termOut, "\n") {
231 if w := displayCells(stripSGRe2e(line)); w > 60 {
232 t.Errorf("%s: line of %d cells at 60 columns: %q", path, w, line)
233 break
234 }
235 }
236 }
237 }
238}
239
240func stripSGRe2e(s string) string {
241 return regexp.MustCompile("\x1b\\[[0-9;]*m").ReplaceAllString(s, "")
242}
243
244func displayCells(s string) int {
245 n := 0
246 for _, r := range s {
247 switch {
248 case unicode.In(r, unicode.Mn, unicode.Me):
249 case width.LookupRune(r).Kind() == width.EastAsianWide || width.LookupRune(r).Kind() == width.EastAsianFullwidth:
250 n += 2
251 default:
252 n++
253 }
200254 }
255 return n
201256}
202257
203258// dbFingerprint hashes every row of every table, per table. Columns that
e2e/ssh_test.go +24
@@ -195,6 +195,30 @@ func (i *instance) ssh(t *testing.T, key string, stdin string, args ...string) (
195195 return out.String(), errOut.String(), code
196196}
197197
198// sshTerm is ssh with a leading --term=<v> on the command line, as the
199// CLI sends it at a terminal: OpenSSH's ControlMaster does not forward a
200// new session's SetEnv, so the term travels in argv instead. An empty
201// term sends nothing.
202func (i *instance) sshTerm(t *testing.T, key, term string, args ...string) (string, string, int) {
203 t.Helper()
204 if term != "" {
205 // "--" stops the local ssh client from parsing --term=... as one of
206 // its own options; it is not part of the remote command line.
207 args = append([]string{"--", "--term=" + term}, args...)
208 }
209 cmd := i.sshCmd(key, args...)
210 var out, errOut strings.Builder
211 cmd.Stdout, cmd.Stderr = &out, &errOut
212 err := cmd.Run()
213 code := 0
214 if ee, ok := err.(*exec.ExitError); ok {
215 code = ee.ExitCode()
216 } else if err != nil {
217 t.Fatalf("ssh: %v", err)
218 }
219 return out.String(), errOut.String(), code
220}
221
198222func TestControlPlaneOverBareSSH(t *testing.T) {
199223 t.Parallel()
200224 inst := startInstance(t)
e2e/term_test.go added +87
@@ -0,0 +1,87 @@
1package e2e
2
3import (
4 "os"
5 "os/exec"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// GITBAY_TERM selects terminal output per session. Stock ssh without it
12// gets the plain rows scripts read.
13func TestTermEnvSelectsTerminalOutput(t *testing.T) {
14 t.Parallel()
15 inst := startInstance(t)
16 key := inst.newKey(t, "alice")
17 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub",
18 "--email", "alice@example.test", "--verified")
19 if _, errOut, code := inst.ssh(t, key, "", "repo", "create", "alice/app"); code != 0 {
20 t.Fatalf("repo create: %d %s", code, errOut)
21 }
22
23 plain, _, _ := inst.sshTerm(t, key, "", "repo", "list")
24 if strings.Contains(plain, "PATH") || !strings.Contains(plain, "alice/app\t") {
25 t.Errorf("plain repo list: %q", plain)
26 }
27 term, _, _ := inst.sshTerm(t, key, "80,color", "repo", "list")
28 if !strings.HasPrefix(term, "\x1b[2mPATH") {
29 t.Errorf("terminal repo list: %q", term)
30 }
31}
32
33// The CLI shares one connection per instance. Each session's terminal
34// selection must reach the server on its own, not the one the master
35// session was opened with — which is why it travels as a leading
36// --term=<v> argument rather than SetEnv: OpenSSH's mux client does not
37// forward a new session's SetEnv onto an existing ControlMaster.
38func TestTermEnvOverMultiplexedSession(t *testing.T) {
39 t.Parallel()
40 inst := startInstance(t)
41 key := inst.newKey(t, "alice")
42 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub",
43 "--email", "alice@example.test", "--verified")
44 inst.ssh(t, key, "", "repo", "create", "alice/app")
45
46 dir, err := os.MkdirTemp("", "gbmux")
47 if err != nil {
48 t.Fatal(err)
49 }
50 t.Cleanup(func() { os.RemoveAll(dir) })
51 sock := filepath.Join(dir, "cm")
52 mux := func(term string) string {
53 t.Helper()
54 cmdArgs := []string{"repo", "list"}
55 if term != "" {
56 // "--" stops the local ssh client from parsing --term=... as one
57 // of its own options; it is not part of the remote command line.
58 cmdArgs = append([]string{"--", "--term=" + term}, cmdArgs...)
59 }
60 cmd := inst.sshCmd(key, cmdArgs...)
61 opts := []string{"-o", "ControlMaster=auto", "-o", "ControlPath=" + sock, "-o", "ControlPersist=30"}
62 for j, a := range cmd.Args {
63 if a == "git@127.0.0.1" {
64 cmd.Args = append(cmd.Args[:j:j], append(opts, cmd.Args[j:]...)...)
65 break
66 }
67 }
68 out, err := cmd.Output()
69 if err != nil {
70 t.Fatalf("ssh %s: %v", term, err)
71 }
72 return string(out)
73 }
74 t.Cleanup(func() {
75 exec.Command("ssh", "-o", "ControlPath="+sock, "-O", "exit", "git@127.0.0.1").Run()
76 })
77
78 if out := mux("80"); !strings.HasPrefix(out, "PATH") {
79 t.Fatalf("master session: %q", out)
80 }
81 if out := mux("80,color"); !strings.HasPrefix(out, "\x1b[2mPATH") {
82 t.Errorf("second session kept the master's GITBAY_TERM: %q", out)
83 }
84 if out := mux(""); strings.Contains(out, "PATH") {
85 t.Errorf("session without GITBAY_TERM got terminal output: %q", out)
86 }
87}
go.mod +1 −1
@@ -16,6 +16,7 @@ require (
1616 golang.org/x/image v0.46.0
1717 golang.org/x/net v0.59.0
1818 golang.org/x/term v0.46.0
19 golang.org/x/text v0.42.0
1920 modernc.org/sqlite v1.59.0
2021)
2122
@@ -34,7 +35,6 @@ require (
3435 github.com/russross/blackfriday/v2 v2.1.0 // indirect
3536 github.com/spf13/pflag v1.0.9 // indirect
3637 golang.org/x/sys v0.48.0 // indirect
37 golang.org/x/text v0.42.0 // indirect
3838 modernc.org/libc v1.75.7 // indirect
3939 modernc.org/mathutil v1.7.1 // indirect
4040 modernc.org/memory v1.12.1 // indirect
internal/control/admin.go +33 −15
@@ -128,13 +128,15 @@ func runAdminUserList(c *Ctx, args []string) int {
128128 ds = append(ds, adminUserRow(u))
129129 }
130130 return c.emitPage(p, ds, next, func(w io.Writer) {
131 tb := c.table(w, "USERNAME", "STATE", "ADMIN", "CREATED", "LAST SEEN")
131132 for _, d := range ds {
132133 mark := ""
133134 if d.Admin {
134135 mark = "admin"
135136 }
136 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.Username, d.State, mark, d.CreatedAt, d.LastSeen)
137 tb.row(cRef(d.Username), cState(d.State), cText(mark), cAge(d.CreatedAt), cAge(d.LastSeen))
137138 }
139 tb.flush()
138140 })
139141}
140142
@@ -257,37 +259,47 @@ func runAdminUserShow(c *Ctx, args []string) int {
257259 }
258260 fmt.Fprintf(w, "repos\t%d\nweb sessions\t%d\n", d.Repos, d.WebSessions)
259261 fmt.Fprintln(w, "keys:")
262 tk := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LAST USED")
260263 for _, k := range d.Keys {
261 fmt.Fprintf(w, " %s\t%s\t%s\t%s\n", k.Fingerprint, k.Algo, k.Scope, k.LastUsedAt)
264 tk.row(cRef(" "+k.Fingerprint), cText(k.Algo), cState(k.Scope), cAge(k.LastUsedAt))
262265 }
266 tk.flush()
263267 fmt.Fprintln(w, "emails:")
268 te := c.table(w, "ADDRESS", "STATE")
264269 for _, e := range d.Emails {
265270 state := "unverified"
266271 if e.Verified {
267272 state = "verified by " + e.VerifiedBy
268273 }
269 mark := ""
274 cells := []cell{cRef(" " + e.Address), cState(state)}
270275 if e.Primary {
271 mark = "\tprimary"
276 cells = append(cells, cText("primary"))
272277 }
273 fmt.Fprintf(w, " %s\t%s%s\n", e.Address, state, mark)
278 te.row(cells...)
274279 }
280 te.flush()
275281 fmt.Fprintln(w, "pgp keys:")
282 tp := c.table(w, "FINGERPRINT")
276283 for _, k := range d.PGPKeys {
277 fmt.Fprintf(w, " %s\n", k.Fingerprint)
284 tp.row(cRef(" " + k.Fingerprint))
278285 }
286 tp.flush()
279287 fmt.Fprintln(w, "orgs:")
288 to := c.table(w, "ORG", "ROLE")
280289 for _, o := range d.Orgs {
281 fmt.Fprintf(w, " %s\t%s\n", o.Org, o.Role)
290 to.row(cRef(" "+o.Org), cState(o.Role))
282291 }
292 to.flush()
283293 fmt.Fprintln(w, "api tokens:")
294 tt := c.table(w, "NAME", "SCOPE", "LAST USED")
284295 for _, t := range d.APITokens {
285296 used := ""
286297 if t.LastUsedAt != nil {
287 used = t.LastUsedAt.UTC().Format(time.RFC3339)
298 used = t.LastUsedAt.UTC().Format(time.RFC3339Nano)
288299 }
289 fmt.Fprintf(w, " %s\t%s\t%s\n", t.Name, t.Scope, strings.TrimSpace(used))
300 tt.row(cRef(" "+t.Name), cState(t.Scope), cAge(used))
290301 }
302 tt.flush()
291303 })
292304}
293305
@@ -382,13 +394,15 @@ func runAdminRepoList(c *Ctx, args []string) int {
382394 ds = append(ds, out{r.Path, r.Visibility, r.Archived, r.CreatedAt, r.LastPush, size})
383395 }
384396 return c.emitPage(p, ds, next, func(w io.Writer) {
397 tb := c.table(w, "PATH", "VISIBILITY", "BYTES", "CREATED", "LAST PUSH")
385398 for _, d := range ds {
386 mark := ""
399 cells := []cell{cRef(d.Path), cState(d.Visibility), cNum(d.Bytes), cAge(d.CreatedAt), cAge(d.LastPush)}
387400 if d.Archived {
388 mark = "\t[archived]"
401 cells = append(cells, cText("[archived]"))
389402 }
390 fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s%s\n", d.Path, d.Visibility, d.Bytes, d.CreatedAt, d.LastPush, mark)
403 tb.row(cells...)
391404 }
405 tb.flush()
392406 })
393407}
394408
@@ -517,6 +531,7 @@ func runAdminRunners(c *Ctx, args []string) int {
517531 return c.emit(d, func(w io.Writer) {
518532 fmt.Fprintf(w, "queue: %d pending; last 24h: %d claimed, wait avg %ds max %ds, %d reaped\n",
519533 queue.Pending, queue.Claimed24h, queue.ClaimWaitAvgS, queue.ClaimWaitMaxS, queue.Reaped24h)
534 tb := c.table(w, "USER", "FINGERPRINT", "LAST SEEN", "SCOPE", "HELD")
520535 for _, r := range runners {
521536 scope := r.Scope
522537 if scope == "" {
@@ -526,8 +541,9 @@ func runAdminRunners(c *Ctx, args []string) int {
526541 if r.BuildNumber != 0 {
527542 held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt)
528543 }
529 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Username, r.Fingerprint, r.LastSeen, scope, held)
544 tb.row(cText(r.Username), cFlex(r.Fingerprint), cAge(r.LastSeen), cText(scope), cText(held))
530545 }
546 tb.flush()
531547 })
532548}
533549
@@ -609,12 +625,14 @@ func runAdminMRPrune(c *Ctx, args []string) int {
609625 return c.fail(protocol.ExitFailure, "%v; the head refs are deleted but the objects are not yet pruned; re-run the same command", err)
610626 }
611627 return c.emit(rows, func(w io.Writer) {
628 tb := c.table(w, "!", "HEAD")
612629 for _, r := range rows {
613630 if r.Head == "" {
614 fmt.Fprintf(w, "!%d\talready gone\n", r.Number)
631 tb.row(cRef(fmt.Sprintf("!%d", r.Number)), cText("already gone"))
615632 continue
616633 }
617 fmt.Fprintf(w, "!%d\t%s\n", r.Number, r.Head)
634 tb.row(cRef(fmt.Sprintf("!%d", r.Number)), cRef(r.Head))
618635 }
636 tb.flush()
619637 })
620638}
internal/control/audit.go +3 −2
@@ -1,7 +1,6 @@
11package control
22
33import (
4 "fmt"
54 "io"
65 "strconv"
76 "strings"
@@ -47,13 +46,15 @@ func runAudit(c *Ctx, args []string) int {
4746 return c.fail(protocol.ExitFailure, "%v", err)
4847 }
4948 return c.emit(entries, func(w io.Writer) {
49 tb := c.table(w, "WHEN", "ACTOR", "ACTION", "DATA")
5050 for _, e := range entries {
5151 actor := e.Actor
5252 if actor == "" {
5353 actor = "-"
5454 }
55 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.CreatedAt, actor, e.Action, e.Data)
55 tb.row(cAge(e.CreatedAt), cText(actor), cText(e.Action), cFlex(e.Data))
5656 }
57 tb.flush()
5758 })
5859}
5960
internal/control/build.go +12 −6
@@ -159,9 +159,11 @@ func runBuildList(c *Ctx, args []string) int {
159159 ds[i].Subject = subjects[ds[i].SHA]
160160 }
161161 return c.emitPage(p, ds, next, func(w io.Writer) {
162 tb := c.table(w, "#", "JOB", "STATUS", "SHA", "REF", "TITLE")
162163 for _, d := range ds {
163 fmt.Fprintf(w, "%d\t%s\t%s\t%.10s\t%s\t%s\n", d.Number, d.Job, d.Status, d.SHA, d.Ref, d.Subject)
164 tb.row(cRef(fmt.Sprintf("%d", d.Number)), cText(d.Job), cState(d.Status), cRef(fmt.Sprintf("%.10s", d.SHA)), cText(d.Ref), cFlex(d.Subject))
164165 }
166 tb.flush()
165167 })
166168}
167169
@@ -259,16 +261,18 @@ func runBuildJobs(c *Ctx, args []string) int {
259261 out = append(out, JobOut{Name: j.Name, Schedule: j.Schedule, Tags: j.Tags})
260262 }
261263 return c.emit(out, func(w io.Writer) {
264 tb := c.table(w, "NAME", "WHEN")
262265 for _, j := range out {
266 when := "on push"
263267 switch {
264268 case j.Schedule != "":
265 fmt.Fprintf(w, "%s\tschedule %s\n", j.Name, j.Schedule)
269 when = "schedule " + j.Schedule
266270 case j.Tags != "":
267 fmt.Fprintf(w, "%s\ttags %s\n", j.Name, j.Tags)
268 default:
269 fmt.Fprintf(w, "%s\ton push\n", j.Name)
271 when = "tags " + j.Tags
270272 }
273 tb.row(cRef(j.Name), cText(when))
271274 }
275 tb.flush()
272276 })
273277}
274278
@@ -365,9 +369,11 @@ func runSecretList(c *Ctx, args []string) int {
365369 return c.fail(protocol.ExitFailure, "%v", err)
366370 }
367371 return c.emit(names, func(w io.Writer) {
372 tb := c.table(w, "NAME")
368373 for _, n := range names {
369 fmt.Fprintln(w, n)
374 tb.row(cRef(n))
370375 }
376 tb.flush()
371377 })
372378}
373379
internal/control/control.go +33 −1
@@ -27,6 +27,9 @@ type Ctx struct {
2727 Stdout io.Writer
2828 Stderr io.Writer
2929 JSON bool
30 // Term is the client's terminal, from GITBAY_TERM. The zero value
31 // is plain output.
32 Term Term
3033 // ViaAPI marks requests arriving over HTTP, from the token API or
3134 // the web. Every command runs there; nothing is held back for SSH
3235 // any more (#234). The flag stays because the rate limiter and the
@@ -40,6 +43,9 @@ type Ctx struct {
4043 // Cmd is the command being run, set by Dispatch, so a usage error can
4144 // print the registered usage rather than a copy of it.
4245 Cmd Command
46 // Argv is the command's arguments after its path, global flags
47 // removed, so output can print a command to run next.
48 Argv []string
4349 // Done, when the surface has one, closes when nobody is reading any
4450 // more: the SSH channel closed or the HTTP request ended. A command
4551 // that runs until something happens (build log --follow) stops on it.
@@ -101,6 +107,21 @@ func Lookup(argv []string) (Command, []string, bool) {
101107// Dispatch runs argv for an authenticated session. The dispatcher — not the
102108// handlers — enforces key scope: control commands require a full-scope key.
103109func Dispatch(c *Ctx, argv []string) int {
110 if len(argv) == 0 {
111 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
112 }
113 // A leading --term=<v> selects terminal output for this session, the
114 // same as GITBAY_TERM. It must come off before Lookup: Lookup matches
115 // argv against a command's Path, and a --term= in front would never
116 // match one.
117 for len(argv) > 0 {
118 v, ok := strings.CutPrefix(argv[0], "--term=")
119 if !ok {
120 break
121 }
122 c.Term = ParseTerm(v)
123 argv = argv[1:]
124 }
104125 if len(argv) == 0 {
105126 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
106127 }
@@ -118,8 +139,13 @@ func Dispatch(c *Ctx, argv []string) int {
118139 c.JSON = true
119140 continue
120141 }
142 if v, ok := strings.CutPrefix(a, "--term="); ok {
143 c.Term = ParseTerm(v)
144 continue
145 }
121146 args = append(args, a)
122147 }
148 c.Argv = args
123149 // A runner-scoped key reaches the runner protocol and nothing else, so
124150 // the key a CI host holds cannot administer the instance.
125151 if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") {
@@ -326,7 +352,13 @@ func runHelp(c *Ctx, args []string) int {
326352 slices.SortFunc(matched, func(a, b helpEntry) int { return strings.Compare(a.Path, b.Path) })
327353 return c.emit(matched, func(w io.Writer) {
328354 for _, e := range matched {
329 fmt.Fprintf(w, "%-24s %s\n", e.Path, e.Summary)
355 summary := e.Summary
356 if c.Term.Cols > 0 {
357 if avail := c.Term.Cols - max(cells(e.Path), 24) - 1; avail > 0 {
358 summary = clip(summary, avail)
359 }
360 }
361 fmt.Fprintf(w, "%-24s %s\n", e.Path, summary)
330362 if prefix != "" {
331363 fmt.Fprintf(w, " %s\n", e.Usage)
332364 }
internal/control/cursor.go +33 −1
@@ -6,12 +6,26 @@ import (
66 "fmt"
77 "io"
88 "reflect"
9 "regexp"
910 "strconv"
1011 "strings"
1112
1213 "gitbay.org/gitbay/internal/protocol"
1314)
1415
16// bareWord matches arguments that need no quoting for the server-side
17// POSIX tokenizer.
18var bareWord = regexp.MustCompile(`^[A-Za-z0-9@%+=:,./_!-]+$`)
19
20// shellWord quotes one argument the way the gitbay CLI does, so a
21// printed command can be pasted.
22func shellWord(arg string) string {
23 if arg != "" && bareWord.MatchString(arg) {
24 return arg
25 }
26 return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
27}
28
1529// Cursor pagination. A cursor is opaque to clients: base64url of
1630// "<kind>:<key>", where key is the sort key of the last row of the
1731// previous page. The kind keeps a cursor minted by one command from
@@ -120,8 +134,26 @@ func (c *Ctx) emitPage(p page, items any, next string, plain func(w io.Writer))
120134 }
121135 return c.emit(out{items, next}, func(w io.Writer) {
122136 plain(w)
123 if next != "" {
137 if next == "" {
138 return
139 }
140 if c.Term.Cols == 0 {
124141 fmt.Fprintf(w, "next\t%s\n", next)
142 return
143 }
144 var again []string
145 for i := 0; i < len(c.Argv); i++ {
146 if c.Argv[i] == "--cursor" {
147 i++
148 continue
149 }
150 again = append(again, c.Argv[i])
151 }
152 cmd := []string{"gitbay", joinPath(c.Cmd.Path)}
153 for _, a := range again {
154 cmd = append(cmd, shellWord(a))
125155 }
156 cmd = append(cmd, "--cursor", next)
157 fmt.Fprintf(c.Stderr, "more: %s\n", strings.Join(cmd, " "))
126158 })
127159}
internal/control/cursor_test.go +54 −1
@@ -1,6 +1,33 @@
11package control
22
3import "testing"
3import (
4 "bytes"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/protocol"
9 "gitbay.org/gitbay/internal/store"
10)
11
12func TestShellWord(t *testing.T) {
13 tests := []struct {
14 input string
15 want string
16 }{
17 {"plain", "plain"},
18 {"foo-bar", "foo-bar"},
19 {"123", "123"},
20 {"foo bar", "'foo bar'"},
21 {"it's", "'it'\\''s'"},
22 {"a'b'c", "'a'\\''b'\\''c'"},
23 {"", "''"},
24 }
25 for _, tt := range tests {
26 if got := shellWord(tt.input); got != tt.want {
27 t.Errorf("shellWord(%q) = %q, want %q", tt.input, got, tt.want)
28 }
29 }
30}
431
532func TestCursorRoundTrip(t *testing.T) {
633 cur := encodeCursor("issue", "42")
@@ -37,3 +64,29 @@ func TestTrimPage(t *testing.T) {
3764 t.Fatalf("unpaged: %v next=%q", items, next)
3865 }
3966}
67
68func TestEmitPageHintsTheNextPageAtATerminal(t *testing.T) {
69 st, repo, uid := newQueueTestRepo(t)
70 for i := 0; i < 3; i++ {
71 if _, err := st.CreateBuild(repo.ID, "unit", "aaa", "main", `["true"]`, "", "", true); err != nil {
72 t.Fatal(err)
73 }
74 }
75 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
76 c.Term = Term{Cols: 100}
77 if code := Dispatch(c, []string{"build", "list", repo.Path(), "--limit", "2"}); code != protocol.ExitOK {
78 t.Fatalf("exit %d: %s", code, errOut)
79 }
80 if strings.Contains(c.Stdout.(*bytes.Buffer).String(), "next\t") {
81 t.Errorf("cursor row on stdout at a terminal")
82 }
83 stderr := errOut.String()
84 want := "more: gitbay build list " + repo.Path() + " --limit 2 --cursor "
85 if !strings.Contains(stderr, want) {
86 t.Errorf("stderr = %q, want %q…", stderr, want)
87 }
88 // Ensure no double space in the output.
89 if strings.Contains(stderr, " ") {
90 t.Errorf("stderr contains double space: %q", stderr)
91 }
92}
internal/control/dashboard.go +93 −45
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "io"
77 "strconv"
8 "strings"
89
910 "gitbay.org/gitbay/internal/buildinfo"
1011 "gitbay.org/gitbay/internal/gitutil"
@@ -163,90 +164,135 @@ func runDashboard(c *Ctx, args []string) int {
163164 }
164165
165166 return c.emit(d, func(w io.Writer) {
167 section := func(title string, header []string, rows [][]cell) {
168 if c.Term.Cols > 0 {
169 fmt.Fprintln(w, c.Term.paint(sgrBold, title))
170 } else {
171 fmt.Fprintln(w, title)
172 }
173 if len(rows) == 0 {
174 fmt.Fprintln(w, " none")
175 return
176 }
177 if c.Term.Cols == 0 {
178 for _, r := range rows {
179 parts := make([]string, len(r))
180 for i, cl := range r {
181 parts[i] = cl.s
182 if cl.kind == kindAge {
183 parts[i] = stamp(cl.s)
184 }
185 }
186 fmt.Fprintf(w, " %s\n", strings.Join(parts, "\t"))
187 }
188 return
189 }
190 tb := c.table(w, header...)
191 for _, r := range rows {
192 tb.row(r...)
193 }
194 tb.flush()
195 }
196 itemRows := func(items []DashboardItem, marker string) [][]cell {
197 rows := make([][]cell, len(items))
198 for i, item := range items {
199 rows[i] = []cell{cRef(fmt.Sprintf("%s%s%d", item.Repo, marker, item.Number)), cFlex(item.Title), cText(item.Author)}
200 }
201 return rows
202 }
203
166204 if d.Unread > 0 {
167205 fmt.Fprintf(w, "unread notifications: %d\n", d.Unread)
168206 }
169 fmt.Fprintln(w, "waiting on your review:")
170 printDashboardItems(w, d.Reviews, "!")
171 fmt.Fprintln(w, "assigned to you:")
172 printDashboardItems(w, d.Assigned, "#")
173 fmt.Fprintln(w, "open merge requests:")
174 printDashboardItems(w, d.MRs, "!")
175 fmt.Fprintln(w, "open issues:")
176 printDashboardItems(w, d.Issues, "#")
177 fmt.Fprintln(w, "pinned:")
178 if len(d.Pinned) == 0 {
179 fmt.Fprintln(w, " none")
180 }
181 for _, p := range d.Pinned {
182 mark := ""
207 itemHeader := []string{"REF", "TITLE", "AUTHOR"}
208 section("waiting on your review:", itemHeader, itemRows(d.Reviews, "!"))
209 section("assigned to you:", itemHeader, itemRows(d.Assigned, "#"))
210 section("open merge requests:", itemHeader, itemRows(d.MRs, "!"))
211 section("open issues:", itemHeader, itemRows(d.Issues, "#"))
212
213 pinnedRows := make([][]cell, len(d.Pinned))
214 for i, p := range d.Pinned {
215 cells := []cell{cRef(p.Path), cState(p.Visibility), cFlex(p.Description)}
183216 if p.Archived {
184 mark = "\t[archived]"
217 cells = append(cells, cText("[archived]"))
185218 }
186 fmt.Fprintf(w, " %s\t%s\t%s%s\n", p.Path, p.Visibility, p.Description, mark)
219 pinnedRows[i] = cells
187220 }
188 fmt.Fprintln(w, "recent activity:")
189 if len(d.Activity) == 0 {
190 fmt.Fprintln(w, " none")
191 }
192 for _, e := range d.Activity {
193 fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", e.CreatedAt, e.Actor, e.Kind, e.Repo, string(e.Data))
194 }
195 fmt.Fprintln(w, "builds:")
196 if len(d.Builds) == 0 {
197 fmt.Fprintln(w, " none")
221 section("pinned:", []string{"PATH", "VISIBILITY", "DESCRIPTION"}, pinnedRows)
222
223 activityRows := make([][]cell, len(d.Activity))
224 for i, e := range d.Activity {
225 activityRows[i] = []cell{cAge(e.CreatedAt), cText(e.Actor), cText(e.Kind), cRef(e.Repo), cFlex(string(e.Data))}
198226 }
199 for _, b := range d.Builds {
200 fmt.Fprintf(w, " %s\t%d\t%s\t%s\t%.10s\t%s\n", b.Repo, b.Number, b.Job, b.Status, b.SHA, b.Ref)
227 section("recent activity:", []string{"WHEN", "ACTOR", "KIND", "REPO", "DATA"}, activityRows)
228
229 buildRows := make([][]cell, len(d.Builds))
230 for i, b := range d.Builds {
231 buildRows[i] = []cell{cRef(b.Repo), cNum(b.Number), cText(b.Job), cState(b.Status), cRef(fmt.Sprintf("%.10s", b.SHA)), cText(b.Ref)}
201232 }
233 section("builds:", []string{"REPO", "#", "JOB", "STATUS", "SHA", "REF"}, buildRows)
234
202235 if d.Server != nil {
203236 fmt.Fprintf(w, "server:\n build %s\n", d.Server.Commit)
204237 }
205238 if q := d.Queues; q != nil {
206239 fmt.Fprintln(w, "queues:")
240
207241 fmt.Fprintf(w, " webhooks\tpending %d\tretrying %d\tfailed %d\n", q.Webhooks.Pending, q.Webhooks.Retrying, q.Webhooks.Failed)
242 twh := c.table(w, "REPO", "URL", "ATTEMPTS", "ERROR")
208243 for _, it := range q.Webhooks.Items {
209 fmt.Fprintf(w, " %s\t%s\tattempts %d\t%s\n", it.Repo, it.URL, it.Attempts, it.LastError)
244 twh.row(cRef(" "+it.Repo), cText(it.URL), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
210245 }
246 twh.flush()
247
211248 fmt.Fprintf(w, " mail\tpending %d\tretrying %d\tfailed %d\n", q.Mail.Pending, q.Mail.Retrying, q.Mail.Failed)
249 tma := c.table(w, "RECIPIENT", "SUBJECT", "ATTEMPTS", "ERROR")
212250 for _, it := range q.Mail.Items {
213 fmt.Fprintf(w, " %s\t%s\tattempts %d\t%s\n", it.Recipient, it.Subject, it.Attempts, it.LastError)
251 tma.row(cRef(" "+it.Recipient), cText(it.Subject), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
214252 }
253 tma.flush()
254
215255 // The device id, not the token: a token is never echoed.
216256 fmt.Fprintf(w, " push\tpending %d\tretrying %d\tfailed %d\n", q.Push.Pending, q.Push.Retrying, q.Push.Failed)
257 tpu := c.table(w, "DEVICE", "TITLE", "ATTEMPTS", "ERROR")
217258 for _, it := range q.Push.Items {
218 fmt.Fprintf(w, " device %d\t%s\tattempts %d\t%s\n", it.DeviceID, it.Title, it.Attempts, it.LastError)
259 tpu.row(cRef(fmt.Sprintf(" device %d", it.DeviceID)), cText(it.Title), cText(fmt.Sprintf("attempts %d", it.Attempts)), cText(it.LastError))
219260 }
261 tpu.flush()
262
220263 fmt.Fprintf(w, " mirrors\tdirty %d\terrors %d\n", q.Mirrors.Dirty, q.Mirrors.Errors)
264 tmi := c.table(w, "REPO", "DIRECTION", "URL", "ERROR")
221265 for _, it := range q.Mirrors.Items {
222 fmt.Fprintf(w, " %s\t%s\t%s\t%s\n", it.Repo, it.Direction, it.URL, it.LastError)
266 tmi.row(cRef(" "+it.Repo), cText(it.Direction), cText(it.URL), cText(it.LastError))
223267 }
268 tmi.flush()
269
224270 fmt.Fprintf(w, " builds\tpending %d\trunning %d\n", q.Builds.Pending, q.Builds.Running)
271 tbq := c.table(w, "REPO", "#", "JOB", "STATUS")
225272 for _, it := range q.Builds.Items {
226273 since := it.StartedAt
227274 if it.Status == "pending" {
228275 since = it.CreatedAt
229276 }
230 fmt.Fprintf(w, " %s\t%d\t%s\t%s since %s\n", it.Repo, it.Number, it.Job, it.Status, since)
277 if c.Term.Cols == 0 {
278 since = stamp(since)
279 } else {
280 since = relAge(since, termNow())
281 }
282 tbq.row(cRef(" "+it.Repo), cNum(it.Number), cText(it.Job), cText(fmt.Sprintf("%s since %s", it.Status, since)))
231283 }
284 tbq.flush()
285
232286 fmt.Fprintf(w, " deps\terrors %d\n", q.Deps.Errors)
287 tde := c.table(w, "REPO", "ERROR")
233288 for _, it := range q.Deps.Items {
234 fmt.Fprintf(w, " %s\t%s\n", it.Repo, it.LastError)
289 tde.row(cRef(" "+it.Repo), cText(it.LastError))
235290 }
291 tde.flush()
236292 }
237293 })
238294}
239295
240func printDashboardItems(w io.Writer, items []DashboardItem, marker string) {
241 if len(items) == 0 {
242 fmt.Fprintln(w, " none")
243 return
244 }
245 for _, item := range items {
246 fmt.Fprintf(w, " %s%s%d\t%s\t%s\n", item.Repo, marker, item.Number, item.Title, item.Author)
247 }
248}
249
250296// feedDefaultLimit caps a bare `feed` call; pagination reaches further
251297// back.
252298const feedDefaultLimit = 50
@@ -292,8 +338,10 @@ func runFeed(c *Ctx, args []string) int {
292338 })
293339 ds := feedOutputs(events)
294340 return c.emitPage(p, ds, next, func(w io.Writer) {
341 tb := c.table(w, "WHEN", "ACTOR", "KIND", "REPO", "DATA")
295342 for _, d := range ds {
296 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.CreatedAt, d.Actor, d.Kind, d.Repo, string(d.Data))
343 tb.row(cAge(d.CreatedAt), cText(d.Actor), cText(d.Kind), cRef(d.Repo), cFlex(string(d.Data)))
297344 }
345 tb.flush()
298346 })
299347}
internal/control/deploykey.go +3 −1
@@ -98,9 +98,11 @@ func runDeployKeyList(c *Ctx, args []string) int {
9898 ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label})
9999 }
100100 return c.emit(ds, func(w io.Writer) {
101 tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL")
101102 for _, d := range ds {
102 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", d.Fingerprint, d.Algo, d.Mode, d.Label)
103 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label))
103104 }
105 tb.flush()
104106 })
105107}
106108
internal/control/explore.go +3 −2
@@ -1,7 +1,6 @@
11package control
22
33import (
4 "fmt"
54 "io"
65 "strings"
76
@@ -75,9 +74,11 @@ func runExplore(c *Ctx, args []string) int {
7574 }
7675 ds, next := trimPage(p, ds, "explore", func(o out) string { return o.Path })
7776 return c.emitPage(p, ds, next, func(w io.Writer) {
77 tb := c.table(w, "PATH", "DESCRIPTION")
7878 for _, d := range ds {
79 fmt.Fprintf(w, "%s\t%s\n", d.Path, d.Description)
79 tb.row(cRef(d.Path), cFlex(d.Description))
8080 }
81 tb.flush()
8182 })
8283}
8384
internal/control/identity.go +3 −1
@@ -83,9 +83,11 @@ func runKeysList(c *Ctx, args []string) int {
8383 ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label})
8484 }
8585 return c.emit(ds, func(w io.Writer) {
86 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL")
8687 for _, d := range ds {
87 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", d.Fingerprint, d.Algo, d.Scope, d.Label)
88 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label))
8889 }
90 tb.flush()
8991 })
9092}
9193
internal/control/issue.go +3 −1
@@ -204,9 +204,11 @@ func runIssueList(c *Ctx, args []string) int {
204204 ds = append(ds, issueToOut(i, false))
205205 }
206206 return c.emitPage(p, ds, next, func(w io.Writer) {
207 tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR")
207208 for _, d := range ds {
208 fmt.Fprintf(w, "#%d\t%s\t%s\t%s\n", d.Number, d.State, d.Title, d.Author)
209 tb.row(cRef(fmt.Sprintf("#%d", d.Number)), cState(d.State), cFlex(d.Title), cText(d.Author))
209210 }
211 tb.flush()
210212 })
211213}
212214
internal/control/label.go +7 −1
@@ -46,9 +46,15 @@ func runLabelList(c *Ctx, args []string) int {
4646 return c.fail(protocol.ExitFailure, "%v", err)
4747 }
4848 return c.emit(labels, func(w io.Writer) {
49 tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS")
4950 for _, l := range labels {
50 fmt.Fprintf(w, "%s\t%s\t%d\t%d%s\n", l.Name, l.Color, l.Issues, l.MRs, map[bool]string{true: "\torg"}[l.Org])
51 cells := []cell{cRef(l.Name), cText(l.Color), cNum(l.Issues), cNum(l.MRs)}
52 if l.Org {
53 cells = append(cells, cText("org"))
54 }
55 tb.row(cells...)
5156 }
57 tb.flush()
5258 })
5359}
5460
internal/control/milestone.go +8 −4
@@ -114,17 +114,19 @@ func emitMilestones(c *Ctx, ms []store.Milestone) int {
114114 ds = append(ds, out{m.Title, m.Description, m.DueDate, m.State, m.OrgID != 0, m.OpenItems, m.ClosedItems})
115115 }
116116 return c.emit(ds, func(w io.Writer) {
117 tb := c.table(w, "TITLE", "STATE", "DUE", "PROGRESS")
117118 for _, d := range ds {
118119 due := d.Due
119120 if due == "" {
120121 due = "-"
121122 }
122 mark := ""
123 cells := []cell{cRef(d.Title), cState(d.State), cText("due " + due), cText(fmt.Sprintf("%d open, %d closed", d.Open, d.Closed))}
123124 if d.Org {
124 mark = "\torg"
125 cells = append(cells, cText("org"))
125126 }
126 fmt.Fprintf(w, "%s\t%s\tdue %s\t%d open, %d closed%s\n", d.Title, d.State, due, d.Open, d.Closed, mark)
127 tb.row(cells...)
127128 }
129 tb.flush()
128130 })
129131}
130132
@@ -241,9 +243,11 @@ func runIssueTemplates(c *Ctx, args []string) int {
241243 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
242244 ts := IssueTemplates(dir, repo.DefaultBranch)
243245 return c.emit(ts, func(w io.Writer) {
246 tb := c.table(w, "NAME")
244247 for _, t := range ts {
245 fmt.Fprintln(w, t.Name)
248 tb.row(cRef(t.Name))
246249 }
250 tb.flush()
247251 })
248252}
249253
internal/control/mirrorcmd.go +3 −1
@@ -101,6 +101,7 @@ func runMirrorList(c *Ctx, args []string) int {
101101 ds = append(ds, out{m.ID, m.Direction, m.URL, m.Username, m.Dirty, m.LastSync, m.LastError})
102102 }
103103 return c.emit(ds, func(w io.Writer) {
104 tb := c.table(w, "ID", "DIRECTION", "URL", "LAST", "STATUS")
104105 for _, d := range ds {
105106 status := "ok"
106107 if d.Pending {
@@ -109,8 +110,9 @@ func runMirrorList(c *Ctx, args []string) int {
109110 if d.LastError != "" {
110111 status = "error: " + d.LastError
111112 }
112 fmt.Fprintf(w, "%d\t%s\t%s\tlast %s\t%s\n", d.ID, d.Direction, d.URL, orDash(d.LastSync), status)
113 tb.row(cRef(fmt.Sprintf("%d", d.ID)), cText(d.Direction), cText(d.URL), cText("last "+orDash(d.LastSync)), cState(status))
113114 }
115 tb.flush()
114116 })
115117}
116118
internal/control/mr.go +11 −7
@@ -499,17 +499,19 @@ func runMRList(c *Ctx, args []string) int {
499499 ds = append(ds, o)
500500 }
501501 return c.emitPage(p, ds, next, func(w io.Writer) {
502 tb := c.table(w, "!", "STATE", "TITLE", "REF")
502503 for _, d := range ds {
503 stacked := ""
504 if d.StackedOn != nil {
505 stacked = fmt.Sprintf("\tstacked on !%d", d.StackedOn.Number)
506 }
507504 state := d.State
508505 if d.Draft {
509506 state = "draft"
510507 }
511 fmt.Fprintf(w, "!%d\t%s\t%s\t%s -> %s%s\n", d.Number, state, d.Title, d.Source, d.TargetRef, stacked)
508 cells := []cell{cRef(fmt.Sprintf("!%d", d.Number)), cState(state), cFlex(d.Title), cText(fmt.Sprintf("%s -> %s", d.Source, d.TargetRef))}
509 if d.StackedOn != nil {
510 cells = append(cells, cText(fmt.Sprintf("stacked on !%d", d.StackedOn.Number)))
511 }
512 tb.row(cells...)
512513 }
514 tb.flush()
513515 })
514516}
515517
@@ -1703,15 +1705,17 @@ func runMRRevisions(c *Ctx, args []string) int {
17031705 return c.fail(protocol.ExitFailure, "%v", err)
17041706 }
17051707 return c.emit(revs, func(w io.Writer) {
1708 tb := c.table(w, "REV", "SHA", "WHEN")
17061709 for _, r := range revs {
17071710 mark := " "
17081711 if r.Current {
17091712 mark = "*"
17101713 }
1711 fmt.Fprintf(w, "%s v%d\t%.10s\t%s\n", mark, r.N, r.SHA, r.CreatedAt)
1714 tb.row(cRef(fmt.Sprintf("%s v%d", mark, r.N)), cRef(fmt.Sprintf("%.10s", r.SHA)), cAge(r.CreatedAt))
17121715 }
1716 tb.flush()
17131717 if len(revs) < 2 {
1714 fmt.Fprintf(w, "\nonly one revision; %s!%d has not been pushed to since it was opened\n",
1718 fmt.Fprintf(c.Stderr, "only one revision; %s!%d has not been pushed to since it was opened\n",
17151719 repo.Path(), mr.Number)
17161720 }
17171721 })
internal/control/notifications.go +7 −3
@@ -303,9 +303,11 @@ func runNotificationsDeviceList(c *Ctx, args []string) int {
303303 Token: ShortToken(d.Token), Added: d.CreatedAt})
304304 }
305305 return c.emit(rows, func(w io.Writer) {
306 tb := c.table(w, "ID", "LABEL", "TOKEN", "ADDED")
306307 for _, r := range rows {
307 fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", r.ID, r.Label, r.Token, r.Added)
308 tb.row(cRef(fmt.Sprintf("%d", r.ID)), cText(r.Label), cText(r.Token), cAge(r.Added))
308309 }
310 tb.flush()
309311 })
310312}
311313
@@ -381,14 +383,16 @@ func runNotificationsList(c *Ctx, args []string) int {
381383 ds = append(ds, out{n.ID, n.RepoPath, n.Kind, n.Actor, n.Summary, n.Path, n.CreatedAt, n.ReadAt})
382384 }
383385 return c.emitPage(p, ds, next, func(w io.Writer) {
386 tb := c.table(w, "ID", "WHEN", "REPO", "EVENT", "PATH")
384387 for _, d := range ds {
385388 mark := "*"
386389 if d.ReadAt != "" {
387390 mark = " "
388391 }
389 fmt.Fprintf(w, "%s %d\t%s\t%s\t%s %s\t%s\n",
390 mark, d.ID, d.CreatedAt, d.Repo, d.Actor, d.Summary, d.Path)
392 tb.row(cRef(fmt.Sprintf("%s %d", mark, d.ID)), cAge(d.CreatedAt), cRef(d.Repo),
393 cFlex(fmt.Sprintf("%s %s", d.Actor, d.Summary)), cText(d.Path))
391394 }
395 tb.flush()
392396 })
393397}
394398
internal/control/org.go +6 −2
@@ -87,9 +87,11 @@ func runOrgList(c *Ctx, args []string) int {
8787 ds = append(ds, out{o.Username, o.Role})
8888 }
8989 return c.emit(ds, func(w io.Writer) {
90 tb := c.table(w, "ORG", "ROLE")
9091 for _, d := range ds {
91 fmt.Fprintf(w, "%s\t%s\n", d.Org, d.Role)
92 tb.row(cRef(d.Org), cState(d.Role))
9293 }
94 tb.flush()
9395 })
9496}
9597
@@ -122,9 +124,11 @@ func runOrgShow(c *Ctx, args []string) int {
122124 }{org.Name, ms}
123125 return c.emit(d, func(w io.Writer) {
124126 fmt.Fprintf(w, "%s\n", d.Org)
127 tb := c.table(w, "USER", "ROLE")
125128 for _, m := range ms {
126 fmt.Fprintf(w, " %s\t%s\n", m.User, m.Role)
129 tb.row(cRef(" "+m.User), cState(m.Role))
127130 }
131 tb.flush()
128132 })
129133}
130134
internal/control/orglabel.go +3 −1
@@ -128,9 +128,11 @@ func runOrgLabelList(c *Ctx, args []string) int {
128128 return c.fail(protocol.ExitFailure, "%v", err)
129129 }
130130 return c.emit(labels, func(w io.Writer) {
131 tb := c.table(w, "NAME", "COLOR", "ISSUES", "MRS")
131132 for _, l := range labels {
132 fmt.Fprintf(w, "%s\t%s\t%d\t%d\n", l.Name, l.Color, l.Issues, l.MRs)
133 tb.row(cRef(l.Name), cText(l.Color), cNum(l.Issues), cNum(l.MRs))
133134 }
135 tb.flush()
134136 })
135137}
136138
internal/control/pagescmd.go +3 −1
@@ -213,8 +213,10 @@ func runDomainList(c *Ctx, args []string) int {
213213 list = append(list, out{d.Domain, state, d.VerifiedAt})
214214 }
215215 return c.emit(list, func(w io.Writer) {
216 tb := c.table(w, "DOMAIN", "STATE")
216217 for _, d := range list {
217 fmt.Fprintf(w, "%s\t%s\n", d.Domain, d.State)
218 tb.row(cRef(d.Domain), cState(d.State))
218219 }
220 tb.flush()
219221 })
220222}
internal/control/read.go +7 −3
@@ -79,12 +79,14 @@ func runRepoRefs(c *Ctx, args []string) int {
7979 d.Tags = append(d.Tags, refOut{Name: ref.Name, SHA: ref.SHA})
8080 }
8181 return c.emit(d, func(w io.Writer) {
82 tb := c.table(w, "KIND", "NAME", "SHA")
8283 for _, ref := range d.Branches {
83 fmt.Fprintf(w, "branch\t%s\t%.10s\n", ref.Name, ref.SHA)
84 tb.row(cText("branch"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
8485 }
8586 for _, ref := range d.Tags {
86 fmt.Fprintf(w, "tag\t%s\t%.10s\n", ref.Name, ref.SHA)
87 tb.row(cText("tag"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA)))
8788 }
89 tb.flush()
8890 })
8991}
9092
@@ -283,13 +285,15 @@ func runRepoTree(c *Ctx, args []string) int {
283285 d.Entries = append(d.Entries, eo)
284286 }
285287 return c.emit(d, func(w io.Writer) {
288 tb := c.table(w, "SHA", "SIZE", "NAME")
286289 for _, e := range d.Entries {
287290 name := e.Name
288291 if e.Type == "tree" {
289292 name += "/"
290293 }
291 fmt.Fprintf(w, "%s\t%s\t%s\n", e.SHA[:min(10, len(e.SHA))], sizeCol(e), name)
294 tb.row(cRef(e.SHA[:min(10, len(e.SHA))]), cText(sizeCol(e)), cFlex(name))
292295 }
296 tb.flush()
293297 })
294298}
295299
internal/control/register.go +5 −2
@@ -62,16 +62,19 @@ func runEmailList(c *Ctx, args []string) int {
6262 ds = append(ds, out{e.Address, e.Verified, e.VerifiedBy, e.Primary})
6363 }
6464 return c.emit(ds, func(w io.Writer) {
65 tb := c.table(w, "ADDRESS", "STATE")
6566 for _, d := range ds {
6667 state := "unverified"
6768 if d.Verified {
6869 state = "verified"
6970 }
71 cells := []cell{cRef(d.Address), cState(state)}
7072 if d.Primary {
71 state += "\tprimary"
73 cells = append(cells, cText("primary"))
7274 }
73 fmt.Fprintf(w, "%s\t%s\n", d.Address, state)
75 tb.row(cells...)
7476 }
77 tb.flush()
7578 })
7679}
7780
internal/control/release.go +3 −1
@@ -246,13 +246,15 @@ func runReleaseList(c *Ctx, args []string) int {
246246 ds = append(ds, releaseToOut(r, false))
247247 }
248248 return c.emitPage(p, ds, next, func(w io.Writer) {
249 tb := c.table(w, "TAG", "TITLE", "ASSETS")
249250 for _, d := range ds {
250251 title := d.Title
251252 if title == d.Tag {
252253 title = ""
253254 }
254 fmt.Fprintf(w, "%s\t%s\t%d asset(s)\n", d.Tag, title, len(d.Assets))
255 tb.row(cRef(d.Tag), cFlex(title), cText(fmt.Sprintf("%d asset(s)", len(d.Assets))))
255256 }
257 tb.flush()
256258 })
257259}
258260
internal/control/repo.go +20 −8
@@ -276,13 +276,15 @@ func runRepoList(c *Ctx, args []string) int {
276276 ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived})
277277 }
278278 return c.emitPage(p, ds, next, func(w io.Writer) {
279 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
279280 for _, d := range ds {
280 mark := ""
281 cells := []cell{cRef(d.Path), cState(d.Visibility), cFlex(d.Description)}
281282 if d.Archived {
282 mark = "\t[archived]"
283 cells = append(cells, cText("[archived]"))
283284 }
284 fmt.Fprintf(w, "%s\t%s\t%s%s\n", d.Path, d.Visibility, d.Description, mark)
285 tb.row(cells...)
285286 }
287 tb.flush()
286288 })
287289}
288290
@@ -611,9 +613,11 @@ func runAccessList(c *Ctx, args []string) int {
611613 ds = append(ds, out{e.Username, e.Role, e.Source})
612614 }
613615 return c.emit(ds, func(w io.Writer) {
616 tb := c.table(w, "USER", "ROLE", "SOURCE")
614617 for _, d := range ds {
615 fmt.Fprintf(w, "%s\t%s\tvia %s\n", d.User, d.Role, d.Source)
618 tb.row(cRef(d.User), cState(d.Role), cText("via "+d.Source))
616619 }
620 tb.flush()
617621 })
618622}
619623
@@ -799,9 +803,11 @@ func runTopicsList(c *Ctx, args []string) int {
799803 return c.fail(protocol.ExitFailure, "%v", err)
800804 }
801805 return c.emit(topics, func(w io.Writer) {
806 tb := c.table(w, "TOPIC")
802807 for _, t := range topics {
803 fmt.Fprintln(w, t)
808 tb.row(cRef(t))
804809 }
810 tb.flush()
805811 })
806812}
807813
@@ -856,9 +862,11 @@ func editTopics(c *Ctx, args []string, add bool) int {
856862 return c.fail(protocol.ExitFailure, "%v", err)
857863 }
858864 return c.emit(now, func(w io.Writer) {
865 tb := c.table(w, "TOPIC")
859866 for _, t := range now {
860 fmt.Fprintln(w, t)
867 tb.row(cRef(t))
861868 }
869 tb.flush()
862870 })
863871}
864872
@@ -902,9 +910,11 @@ func runRepoSearch(c *Ctx, args []string) int {
902910 ds = append(ds, out{r.Path(), r.Visibility, desc, topics})
903911 }
904912 return c.emit(ds, func(w io.Writer) {
913 tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION")
905914 for _, d := range ds {
906 fmt.Fprintf(w, "%s\t%s\t%s\n", d.Path, d.Visibility, d.Description)
915 tb.row(cRef(d.Path), cState(d.Visibility), cFlex(d.Description))
907916 }
917 tb.flush()
908918 })
909919}
910920
@@ -1070,9 +1080,11 @@ func runRepoBookmarks(c *Ctx, args []string) int {
10701080 })
10711081 }
10721082 return c.emit(out, func(w io.Writer) {
1083 tb := c.table(w, "PATH", "COUNT", "DESCRIPTION")
10731084 for _, b := range out {
1074 fmt.Fprintf(w, "%s\t%d\t%s\n", b.Path, b.Bookmarks, b.Description)
1085 tb.row(cRef(b.Path), cNum(int64(b.Bookmarks)), cFlex(b.Description))
10751086 }
1087 tb.flush()
10761088 })
10771089}
10781090
internal/control/runnerrepo.go +3 −1
@@ -109,6 +109,7 @@ func runRepoRunnerList(c *Ctx, args []string) int {
109109 runners = []store.RepoRunner{}
110110 }
111111 return c.emit(runners, func(w io.Writer) {
112 tb := c.table(w, "FINGERPRINT", "ALGO", "USER", "SEEN", "HELD")
112113 for _, r := range runners {
113114 seen := r.LastSeen
114115 if seen == "" {
@@ -118,8 +119,9 @@ func runRepoRunnerList(c *Ctx, args []string) int {
118119 if r.BuildNumber != 0 {
119120 held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt)
120121 }
121 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Fingerprint, r.Algo, r.Username, seen, held)
122 tb.row(cRef(r.Fingerprint), cText(r.Algo), cText(r.Username), cAge(seen), cText(held))
122123 }
124 tb.flush()
123125 })
124126}
125127
internal/control/search.go +21 −9
@@ -97,17 +97,29 @@ func runSearch(c *Ctx, args []string) int {
9797 if err != nil {
9898 return c.fail(protocol.ExitFailure, "%v", err)
9999 }
100 return c.emit(results, func(w io.Writer) {
101 for _, r := range results {
102 switch r.Kind {
103 case "repo":
104 fmt.Fprintf(w, "repo\t%s\t%s\n", r.Repo, r.Title)
105 default:
106 fmt.Fprintf(w, "%s\t%s%s%d\t%s\t%s\n", r.Kind, r.Repo,
107 SearchMarker(r.Kind), r.Number, r.State, r.Title)
100 return c.emit(results, func(w io.Writer) { writeSearchTable(c, w, results) })
101}
102
103// writeSearchTable renders search results: a repo hit has no ref number or
104// state, an issue/mr hit has both. Plain output keeps a repo row at 3
105// cells, matching what it always printed. At a terminal the header still
106// reserves a STATE column, so a repo row gets an empty state cell there —
107// otherwise its title would render under STATE instead of TITLE.
108func writeSearchTable(c *Ctx, w io.Writer, results []SearchResult) {
109 tb := c.table(w, "KIND", "REF", "STATE", "TITLE")
110 for _, r := range results {
111 switch r.Kind {
112 case "repo":
113 if c.Term.Cols > 0 {
114 tb.row(cText("repo"), cRef(r.Repo), cState(""), cFlex(r.Title))
115 } else {
116 tb.row(cText("repo"), cRef(r.Repo), cFlex(r.Title))
108117 }
118 default:
119 tb.row(cText(r.Kind), cRef(fmt.Sprintf("%s%s%d", r.Repo, SearchMarker(r.Kind), r.Number)), cState(r.State), cFlex(r.Title))
109120 }
110 })
121 }
122 tb.flush()
111123}
112124
113125// SearchMarker is the sigil a result's number carries, shared with the web
internal/control/search_test.go added +54
@@ -0,0 +1,54 @@
1package control
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7)
8
9// A repo hit has no state, an issue/mr hit does. At a terminal both must
10// still put TITLE under the TITLE header, not have the repo's title slide
11// left under STATE.
12func TestSearchTableAlignsTitleAtTerminal(t *testing.T) {
13 results := []SearchResult{
14 {Kind: "repo", Repo: "alice/webapp", Title: "a web application"},
15 {Kind: "issue", Repo: "alice/webapp", Number: 4, Title: "memory leak", State: "open"},
16 }
17 var b bytes.Buffer
18 writeSearchTable(&Ctx{Term: Term{Cols: 100}}, &b, results)
19
20 lines := strings.Split(strings.TrimRight(b.String(), "\n"), "\n")
21 if len(lines) != 3 {
22 t.Fatalf("want header + 2 rows, got %d lines:\n%s", len(lines), b.String())
23 }
24 header, repoRow, issueRow := lines[0], lines[1], lines[2]
25 titleAt := strings.Index(header, "TITLE")
26 repoTitleAt := strings.Index(repoRow, "a web application")
27 issueTitleAt := strings.Index(issueRow, "memory leak")
28 if titleAt < 0 || repoTitleAt < 0 || issueTitleAt < 0 {
29 t.Fatalf("columns not found:\n%s", b.String())
30 }
31 if repoTitleAt != issueTitleAt {
32 t.Errorf("titles not aligned: repo row at %d, issue row at %d\n%s", repoTitleAt, issueTitleAt, b.String())
33 }
34 if repoTitleAt != titleAt {
35 t.Errorf("title not under TITLE header: header at %d, repo row at %d\n%s", titleAt, repoTitleAt, b.String())
36 }
37}
38
39// Plain output has no header to align to, so a repo row stays 3 cells —
40// bytes must not change from before the terminal fix.
41func TestSearchTablePlainRepoRowIsThreeCells(t *testing.T) {
42 results := []SearchResult{
43 {Kind: "repo", Repo: "alice/webapp", Title: "a web application"},
44 {Kind: "issue", Repo: "alice/webapp", Number: 4, Title: "memory leak", State: "open"},
45 }
46 var b bytes.Buffer
47 writeSearchTable(&Ctx{}, &b, results)
48
49 want := "repo\talice/webapp\ta web application\n" +
50 "issue\talice/webapp#4\topen\tmemory leak\n"
51 if b.String() != want {
52 t.Errorf("plain:\n%q\nwant\n%q", b.String(), want)
53 }
54}
internal/control/sig.go +7 −2
@@ -80,9 +80,11 @@ func runPGPList(c *Ctx, args []string) int {
8080 ds = append(ds, out{k.Fingerprint, k.UIDsJSON, k.ExpiresAt, k.RevokedAt})
8181 }
8282 return c.emit(ds, func(w io.Writer) {
83 tb := c.table(w, "FINGERPRINT", "EMAILS")
8384 for _, d := range ds {
84 fmt.Fprintf(w, "%s\t%s\n", d.Fingerprint, d.Emails)
85 tb.row(cRef(d.Fingerprint), cText(d.Emails))
8586 }
87 tb.flush()
8688 })
8789}
8890
@@ -211,9 +213,12 @@ func runRepoLog(c *Ctx, args []string) int {
211213 ds = append(ds, d)
212214 }
213215 return c.emit(ds, func(w io.Writer) {
216 tb := c.table(w, "SHA", "STATE", "SUBJECT", "AUTHOR")
214217 for _, d := range ds {
215 fmt.Fprintf(w, "%.10s %-22s %s (%s <%s>)\n", d.SHA, d.Signature.State, d.Subject, d.AuthorName, d.AuthorEmail)
218 tb.row(cRef(fmt.Sprintf("%.10s", d.SHA)), cState(d.Signature.State), cFlex(d.Subject),
219 cText(fmt.Sprintf("(%s <%s>)", d.AuthorName, d.AuthorEmail)))
216220 }
221 tb.flush()
217222 })
218223}
219224
internal/control/snippet.go +6 −2
@@ -202,9 +202,11 @@ func runSnippetShow(c *Ctx, args []string) int {
202202 fmt.Fprintf(w, "%s\n", sn.Description)
203203 }
204204 fmt.Fprintf(w, "%s\nupdated %s\n", snippetURL(c, sn), sn.UpdatedAt)
205 tb := c.table(w, "NAME", "SIZE")
205206 for _, f := range files {
206 fmt.Fprintf(w, " %s\t%d bytes\n", f.Name, f.Size)
207 tb.row(cRef(" "+f.Name), cText(fmt.Sprintf("%d bytes", f.Size)))
207208 }
209 tb.flush()
208210 })
209211}
210212
@@ -238,6 +240,7 @@ func runSnippetList(c *Ctx, args []string) int {
238240 items = append(items, snippetOut(c, sn))
239241 }
240242 return c.emitPage(p, items, next, func(w io.Writer) {
243 tb := c.table(w, "ID", "VISIBILITY", "FILES", "DESCRIPTION")
241244 for _, sn := range rows {
242245 names := ""
243246 for i, f := range sn.Files {
@@ -246,8 +249,9 @@ func runSnippetList(c *Ctx, args []string) int {
246249 }
247250 names += f.Name
248251 }
249 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", sn.PublicID, sn.Visibility, names, sn.Description)
252 tb.row(cRef(sn.PublicID), cState(sn.Visibility), cText(names), cFlex(sn.Description))
250253 }
254 tb.flush()
251255 })
252256}
253257
internal/control/status.go +4 −2
@@ -118,10 +118,12 @@ func runStatusList(c *Ctx, args []string) int {
118118 Statuses []out `json:"statuses"`
119119 }{full, combinedOf(statuses), ds}
120120 return c.emit(d, func(w io.Writer) {
121 fmt.Fprintf(w, "combined\t%s\t%.10s\n", orNone(d.Combined), d.SHA)
121 tb := c.table(w, "CONTEXT", "STATE", "DESCRIPTION")
122 tb.row(cText("combined"), cState(orNone(d.Combined)), cText(fmt.Sprintf("%.10s", d.SHA)))
122123 for _, x := range ds {
123 fmt.Fprintf(w, "%s\t%s\t%s\n", x.Context, x.State, x.Description)
124 tb.row(cText(x.Context), cState(x.State), cFlex(x.Description))
124125 }
126 tb.flush()
125127 })
126128}
127129
internal/control/table.go added +200
@@ -0,0 +1,200 @@
1package control
2
3import (
4 "io"
5 "strconv"
6 "strings"
7)
8
9type cellKind int
10
11const (
12 kindText cellKind = iota
13 kindFlex
14 kindRef
15 kindState
16 kindAge
17 kindNum
18)
19
20// cell is one column of a table row. The kind decides colour, time
21// format, and whether the column may be clipped to fit the terminal.
22type cell struct {
23 kind cellKind
24 s string
25}
26
27func cRef(s string) cell { return cell{kindRef, s} }
28func cState(s string) cell { return cell{kindState, s} }
29func cText(s string) cell { return cell{kindText, s} }
30func cFlex(s string) cell { return cell{kindFlex, s} }
31func cAge(ts string) cell { return cell{kindAge, ts} }
32func cNum(n int64) cell { return cell{kindNum, strconv.FormatInt(n, 10)} }
33
34// table is a list command's rows. Plain, each row is written as it
35// comes, tab-separated with no header. At a terminal rows are held
36// until flush, then written under a header, padded, and fitted to the
37// width.
38type table struct {
39 term Term
40 w io.Writer
41 header []string
42 rows [][]cell
43}
44
45func (c *Ctx) table(w io.Writer, header ...string) *table {
46 return &table{term: c.Term, w: w, header: header}
47}
48
49func (t *table) row(cs ...cell) {
50 if t.term.Cols == 0 {
51 parts := make([]string, len(cs))
52 for i, c := range cs {
53 if c.kind == kindAge {
54 parts[i] = stamp(c.s)
55 } else {
56 parts[i] = c.s
57 }
58 }
59 io.WriteString(t.w, strings.Join(parts, "\t")+"\n")
60 return
61 }
62 now := termNow()
63 for i := range cs {
64 if cs[i].kind == kindAge {
65 cs[i].s = relAge(cs[i].s, now)
66 }
67 }
68 t.rows = append(t.rows, cs)
69}
70
71func (t *table) flush() {
72 if t.term.Cols == 0 || len(t.rows) == 0 {
73 return
74 }
75 // The column count is never smaller than the longest row: a row with
76 // more cells than the header has still gets every cell rendered, the
77 // header just shows blank above the ones it doesn't name.
78 n := len(t.header)
79 for _, r := range t.rows {
80 n = max(n, len(r))
81 }
82 widths := make([]int, n)
83 for i, h := range t.header {
84 widths[i] = cells(h)
85 }
86 for _, r := range t.rows {
87 for i := 0; i < len(r); i++ {
88 widths[i] = max(widths[i], cells(r[i].s))
89 }
90 }
91 t.fit(widths)
92
93 var b strings.Builder
94 line := make([]string, n)
95 for i := range line {
96 if i < len(t.header) {
97 line[i] = t.header[i]
98 }
99 }
100 b.WriteString(t.term.paint(sgrDim, t.join(line, widths)) + "\n")
101 for _, r := range t.rows {
102 for i := 0; i < n; i++ {
103 s := ""
104 if i < len(r) {
105 s = clip(r[i].s, widths[i])
106 }
107 line[i] = s
108 }
109 b.WriteString(t.joinRow(r, line, widths) + "\n")
110 }
111 io.WriteString(t.w, b.String())
112}
113
114// fit shrinks columns until a row fits the terminal: the flexible
115// column first, down to 8 cells, then the other text columns from the
116// right, down to 8 each.
117func (t *table) fit(widths []int) {
118 total := func() int {
119 s := 2 * (len(widths) - 1)
120 for _, w := range widths {
121 s += w
122 }
123 return s
124 }
125 kinds := make([]cellKind, len(widths))
126 for i := range widths {
127 for _, r := range t.rows {
128 if i < len(r) {
129 kinds[i] = r[i].kind
130 break
131 }
132 }
133 }
134 shrink := func(i int) {
135 if over := total() - t.term.Cols; over > 0 && widths[i] > 8 {
136 widths[i] = max(8, widths[i]-over)
137 }
138 }
139 for i, k := range kinds {
140 if k == kindFlex {
141 shrink(i)
142 }
143 }
144 for i := len(kinds) - 1; i >= 0; i-- {
145 if kinds[i] == kindText {
146 shrink(i)
147 }
148 }
149}
150
151// join pads every column but the last and separates them by two spaces.
152func (t *table) join(line []string, widths []int) string {
153 var b strings.Builder
154 for i, s := range line {
155 if i > 0 {
156 b.WriteString(" ")
157 }
158 if i == len(line)-1 {
159 b.WriteString(s)
160 } else {
161 b.WriteString(pad(s, widths[i]))
162 }
163 }
164 return b.String()
165}
166
167// joinRow is join with state cells coloured after padding, so the
168// SGR bytes never count against the width.
169func (t *table) joinRow(r []cell, line []string, widths []int) string {
170 var b strings.Builder
171 for i, s := range line {
172 if i > 0 {
173 b.WriteString(" ")
174 }
175 padding := ""
176 if i < len(line)-1 {
177 padding = strings.Repeat(" ", max(0, widths[i]-cells(s)))
178 }
179 if i < len(r) && r[i].kind == kindState {
180 s = t.term.paint(stateColor(s), s)
181 }
182 b.WriteString(s + padding)
183 }
184 return b.String()
185}
186
187// stripSGR removes SGR sequences, for tests and width checks.
188func stripSGR(s string) string {
189 var b strings.Builder
190 for i := 0; i < len(s); i++ {
191 if s[i] == 0x1b {
192 if j := strings.IndexByte(s[i:], 'm'); j >= 0 {
193 i += j
194 continue
195 }
196 }
197 b.WriteByte(s[i])
198 }
199 return b.String()
200}
internal/control/table_test.go added +110
@@ -0,0 +1,110 @@
1package control
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7 "time"
8)
9
10func fixtureTable(c *Ctx, w *bytes.Buffer) {
11 tb := c.table(w, "#", "STATE", "TITLE", "AUTHOR")
12 tb.row(cRef("#252"), cState("open"), cFlex("Dependency updates available for every module"), cText("gitbay-bot"))
13 tb.row(cRef("#12"), cState("closed"), cFlex("Android app"), cText("cmc"))
14 tb.flush()
15}
16
17func TestTablePlainIsTabs(t *testing.T) {
18 var b bytes.Buffer
19 fixtureTable(&Ctx{}, &b)
20 want := "#252\topen\tDependency updates available for every module\tgitbay-bot\n" +
21 "#12\tclosed\tAndroid app\tcmc\n"
22 if b.String() != want {
23 t.Errorf("plain:\n%q\nwant\n%q", b.String(), want)
24 }
25}
26
27func TestTableTerminalFits(t *testing.T) {
28 var b bytes.Buffer
29 fixtureTable(&Ctx{Term: Term{Cols: 40}}, &b)
30 want := "# STATE TITLE AUTHOR\n" +
31 "#252 open Dependency up… gitbay-bot\n" +
32 "#12 closed Android app cmc\n"
33 if b.String() != want {
34 t.Errorf("terminal:\n%s\nwant\n%s", b.String(), want)
35 }
36}
37
38func TestTableColourOnlyAddsSGR(t *testing.T) {
39 var mono, colour bytes.Buffer
40 fixtureTable(&Ctx{Term: Term{Cols: 40}}, &mono)
41 fixtureTable(&Ctx{Term: Term{Cols: 40, Color: true}}, &colour)
42 if !strings.Contains(colour.String(), sgrGreen+"open"+sgrReset) {
43 t.Errorf("open not green: %q", colour.String())
44 }
45 if !strings.HasPrefix(colour.String(), sgrDim) {
46 t.Errorf("header not dim: %q", colour.String())
47 }
48 if stripSGR(colour.String()) != mono.String() {
49 t.Errorf("colour changed the layout:\n%s\nvs\n%s", stripSGR(colour.String()), mono.String())
50 }
51}
52
53func TestTableAgesAndPlainStamps(t *testing.T) {
54 termNow = func() time.Time { return time.Date(2026, 9, 23, 12, 0, 1, 0, time.UTC) }
55 t.Cleanup(func() { termNow = time.Now })
56 var plain, term bytes.Buffer
57 for _, c := range []struct {
58 ctx *Ctx
59 w *bytes.Buffer
60 }{{&Ctx{}, &plain}, {&Ctx{Term: Term{Cols: 80}}, &term}} {
61 tb := c.ctx.table(c.w, "#", "UPDATED")
62 tb.row(cRef("#1"), cAge("2026-09-23T10:00:00.123Z"))
63 tb.flush()
64 }
65 if plain.String() != "#1\t2026-09-23T10:00:00Z\n" {
66 t.Errorf("plain = %q", plain.String())
67 }
68 if term.String() != "# UPDATED\n#1 2h ago\n" {
69 t.Errorf("term = %q", term.String())
70 }
71}
72
73// A row may carry a cell beyond what the header names — repo list's
74// trailing [archived] marker, only present on some rows. flush must
75// still render it, not silently drop it because it falls past
76// len(header).
77func TestTableKeepsCellsBeyondTheHeader(t *testing.T) {
78 var b bytes.Buffer
79 tb := (&Ctx{Term: Term{Cols: 80}}).table(&b, "PATH", "VISIBILITY", "DESCRIPTION")
80 tb.row(cRef("a/x"), cState("public"), cFlex("one"))
81 tb.row(cRef("a/y"), cState("public"), cFlex("two"), cText("[archived]"))
82 tb.flush()
83
84 out := b.String()
85 if !strings.Contains(out, "[archived]") {
86 t.Fatalf("archived marker dropped:\n%s", out)
87 }
88 lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
89 if len(lines) != 3 {
90 t.Fatalf("want 3 lines, got %d:\n%s", len(lines), out)
91 }
92 header, row1, row2 := lines[0], lines[1], lines[2]
93 descAt := strings.Index(header, "DESCRIPTION")
94 oneAt := strings.Index(row1, "one")
95 twoAt := strings.Index(row2, "two")
96 if descAt < 0 || oneAt < 0 || twoAt < 0 {
97 t.Fatalf("columns not found:\n%s", out)
98 }
99 if descAt != oneAt || descAt != twoAt {
100 t.Errorf("DESCRIPTION column not aligned: header at %d, row1 at %d, row2 at %d\n%s", descAt, oneAt, twoAt, out)
101 }
102}
103
104func TestTableEmptyPrintsNothing(t *testing.T) {
105 var b bytes.Buffer
106 (&Ctx{Term: Term{Cols: 80}}).table(&b, "#").flush()
107 if b.Len() != 0 {
108 t.Errorf("empty table printed %q", b.String())
109 }
110}
internal/control/teams.go +6 −2
@@ -143,9 +143,11 @@ func runTeamList(c *Ctx, args []string) int {
143143 names = append(names, t.Name)
144144 }
145145 return c.emit(names, func(w io.Writer) {
146 tb := c.table(w, "TEAM")
146147 for _, n := range names {
147 fmt.Fprintln(w, n)
148 tb.row(cRef(n))
148149 }
150 tb.flush()
149151 })
150152}
151153
@@ -176,9 +178,11 @@ func runTeamShow(c *Ctx, args []string) int {
176178 }{team.Name, members, grants}
177179 return c.emit(d, func(w io.Writer) {
178180 fmt.Fprintf(w, "%s/%s\nmembers: %s\n", org.Name, team.Name, strings.Join(members, ", "))
181 tb := c.table(w, "REPO", "ROLE")
179182 for _, g := range grants {
180 fmt.Fprintf(w, "%s\t%s\n", g.RepoPath, g.Role)
183 tb.row(cRef(g.RepoPath), cState(g.Role))
181184 }
185 tb.flush()
182186 })
183187}
184188
internal/control/term.go added +171
@@ -0,0 +1,171 @@
1package control
2
3import (
4 "fmt"
5 "strconv"
6 "strings"
7 "time"
8 "unicode"
9 "unicode/utf8"
10
11 "golang.org/x/text/width"
12)
13
14// Term is what the client said about its terminal (GITBAY_TERM). The
15// zero value is plain output: tab-separated rows, no header, no colour,
16// which is what stock ssh, the API and the web get.
17type Term struct {
18 Cols int
19 Color bool
20}
21
22// ParseTerm reads "<cols>[,color]". Anything else, or a width outside
23// 40 to 1000, is plain output.
24func ParseTerm(v string) Term {
25 cols, opt, hasOpt := strings.Cut(v, ",")
26 n, err := strconv.Atoi(cols)
27 if err != nil || n < 40 || n > 1000 {
28 return Term{}
29 }
30 switch {
31 case !hasOpt:
32 return Term{Cols: n}
33 case opt == "color":
34 return Term{Cols: n, Color: true}
35 }
36 return Term{}
37}
38
39const (
40 sgrReset = "\x1b[0m"
41 sgrBold = "\x1b[1m"
42 sgrDim = "\x1b[2m"
43 sgrUnderline = "\x1b[4m"
44 sgrRed = "\x1b[31m"
45 sgrGreen = "\x1b[32m"
46 sgrMagenta = "\x1b[35m"
47)
48
49// paint wraps s in an SGR sequence when colour is on.
50func (t Term) paint(sgr, s string) string {
51 if !t.Color || sgr == "" || s == "" {
52 return s
53 }
54 return sgr + s + sgrReset
55}
56
57// stateColor maps a state word to the web's state tokens: --ok green,
58// --done magenta, --bad red, --neutral dim.
59func stateColor(s string) string {
60 switch s {
61 case "open", "success", "approved", "active":
62 return sgrGreen
63 case "merged":
64 return sgrMagenta
65 case "failed", "failure", "error", "changes requested":
66 return sgrRed
67 case "closed", "draft", "pending", "canceled", "cancelled", "archived", "disabled":
68 return sgrDim
69 }
70 return ""
71}
72
73// cells is the width of s in terminal cells: SGR sequences and
74// combining marks take none, East Asian wide and fullwidth runes two.
75func cells(s string) int {
76 n := 0
77 for i := 0; i < len(s); {
78 if s[i] == 0x1b {
79 j := strings.IndexByte(s[i:], 'm')
80 if j < 0 {
81 break
82 }
83 i += j + 1
84 continue
85 }
86 r, size := utf8.DecodeRuneInString(s[i:])
87 i += size
88 n += runeCells(r)
89 }
90 return n
91}
92
93func runeCells(r rune) int {
94 if unicode.In(r, unicode.Mn, unicode.Me) || r == '‍' {
95 return 0
96 }
97 switch width.LookupRune(r).Kind() {
98 case width.EastAsianWide, width.EastAsianFullwidth:
99 return 2
100 }
101 return 1
102}
103
104// clip cuts s to at most w cells, ending in "…" when anything was cut.
105// s must carry no SGR sequences: colour goes on after clipping.
106func clip(s string, w int) string {
107 if cells(s) <= w {
108 return s
109 }
110 var b strings.Builder
111 used := 0
112 for _, r := range s {
113 rc := runeCells(r)
114 if used+rc > w-1 {
115 break
116 }
117 b.WriteRune(r)
118 used += rc
119 }
120 return b.String() + "…"
121}
122
123// pad right-pads s with spaces to w cells.
124func pad(s string, w int) string {
125 return s + strings.Repeat(" ", max(0, w-cells(s)))
126}
127
128// termNow is the clock ages are measured against; tests pin it.
129var termNow = time.Now
130
131// parseStamp reads a stored timestamp: RFC3339 as the store writes it,
132// or SQLite's datetime() form.
133func parseStamp(s string) (time.Time, bool) {
134 for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05"} {
135 if t, err := time.Parse(layout, s); err == nil {
136 return t.UTC(), true
137 }
138 }
139 return time.Time{}, false
140}
141
142// stamp is a stored timestamp in plain output: RFC3339 to the second.
143func stamp(s string) string {
144 t, ok := parseStamp(s)
145 if !ok {
146 return s
147 }
148 return t.Format("2006-01-02T15:04:05Z")
149}
150
151// relAge is a stored timestamp as a table shows it at a terminal.
152func relAge(s string, now time.Time) string {
153 t, ok := parseStamp(s)
154 if !ok {
155 return s
156 }
157 d := max(now.Sub(t), 0)
158 switch {
159 case d < time.Minute:
160 return "just now"
161 case d < time.Hour:
162 return fmt.Sprintf("%dm ago", int(d/time.Minute))
163 case d < 24*time.Hour:
164 return fmt.Sprintf("%dh ago", int(d/time.Hour))
165 case d < 14*24*time.Hour:
166 return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
167 case d < 56*24*time.Hour:
168 return fmt.Sprintf("%dw ago", int(d/(7*24*time.Hour)))
169 }
170 return t.Format("2006-01-02")
171}
internal/control/term_test.go added +81
@@ -0,0 +1,81 @@
1package control
2
3import (
4 "testing"
5 "time"
6)
7
8func TestParseTerm(t *testing.T) {
9 cases := map[string]Term{
10 "120": {Cols: 120},
11 "120,color": {Cols: 120, Color: true},
12 "40": {Cols: 40},
13 "39": {},
14 "": {},
15 "abc": {},
16 "80,blink": {},
17 "80,": {},
18 "5000": {},
19 }
20 for in, want := range cases {
21 if got := ParseTerm(in); got != want {
22 t.Errorf("ParseTerm(%q) = %+v, want %+v", in, got, want)
23 }
24 }
25}
26
27func TestCells(t *testing.T) {
28 cases := map[string]int{
29 "abc": 3,
30 "日本": 4,
31 "é": 1,
32 "é": 1,
33 "\x1b[32mopen\x1b[0m": 4,
34 "": 0,
35 }
36 for in, want := range cases {
37 if got := cells(in); got != want {
38 t.Errorf("cells(%q) = %d, want %d", in, got, want)
39 }
40 }
41}
42
43func TestClip(t *testing.T) {
44 if got := clip("Dependency updates available", 14); got != "Dependency up…" {
45 t.Errorf("clip = %q", got)
46 }
47 if got := clip("short", 14); got != "short" {
48 t.Errorf("clip = %q", got)
49 }
50 if got := clip("日本語のタイトル", 7); got != "日本語…" {
51 t.Errorf("clip wide = %q", got)
52 }
53}
54
55func TestStampAndRelAge(t *testing.T) {
56 if got := stamp("2026-09-23T23:26:00.570Z"); got != "2026-09-23T23:26:00Z" {
57 t.Errorf("stamp = %q", got)
58 }
59 if got := stamp("2026-09-23 23:26:00"); got != "2026-09-23T23:26:00Z" {
60 t.Errorf("stamp sqlite = %q", got)
61 }
62 if got := stamp("garbage"); got != "garbage" {
63 t.Errorf("stamp garbage = %q", got)
64 }
65 now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
66 cases := map[string]string{
67 "2026-09-23T11:59:30Z": "just now",
68 "2026-09-23T11:55:00Z": "5m ago",
69 "2026-09-23T10:00:00Z": "2h ago",
70 "2026-09-20T12:00:00Z": "3d ago",
71 "2026-09-02T12:00:00Z": "3w ago",
72 "2026-06-01T12:00:00Z": "2026-06-01",
73 "2026-09-24T12:00:00Z": "just now",
74 "not a time": "not a time",
75 }
76 for in, want := range cases {
77 if got := relAge(in, now); got != want {
78 t.Errorf("relAge(%q) = %q, want %q", in, got, want)
79 }
80 }
81}
internal/control/token.go +9 −2
@@ -96,13 +96,20 @@ func runTokenList(c *Ctx, args []string) int {
9696 ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt})
9797 }
9898 return c.emit(ds, func(w io.Writer) {
99 tb := c.table(w, "NAME", "SCOPE", "EXPIRES")
99100 for _, d := range ds {
100101 exp := "never expires"
101102 if d.ExpiresAt != nil {
102 exp = "expires " + d.ExpiresAt.UTC().Format(time.RFC3339)
103 ts := d.ExpiresAt.UTC().Format(time.RFC3339Nano)
104 if c.Term.Cols == 0 {
105 exp = "expires " + stamp(ts)
106 } else {
107 exp = "expires " + relAge(ts, termNow())
108 }
103109 }
104 fmt.Fprintf(w, "%s\t%s\t%s\n", d.Name, d.Scope, exp)
110 tb.row(cRef(d.Name), cState(d.Scope), cText(exp))
105111 }
112 tb.flush()
106113 })
107114}
108115
internal/control/web.go +9 −1
@@ -33,9 +33,17 @@ func runWebSessionsList(c *Ctx, args []string) int {
3333 return c.fail(protocol.ExitFailure, "%v", err)
3434 }
3535 return c.emit(sessions, func(w io.Writer) {
36 tb := c.table(w, "ID", "SINCE", "UNTIL")
3637 for _, s := range sessions {
37 fmt.Fprintf(w, "%s\tsince %s\tuntil %s\n", s.ID, s.CreatedAt, s.ExpiresAt)
38 since, until := s.CreatedAt, s.ExpiresAt
39 if c.Term.Cols == 0 {
40 since, until = stamp(since), stamp(until)
41 } else {
42 since, until = relAge(since, termNow()), relAge(until, termNow())
43 }
44 tb.row(cRef(s.ID), cText("since "+since), cText("until "+until))
3845 }
46 tb.flush()
3947 })
4048}
4149
internal/control/webhook.go +9 −4
@@ -90,9 +90,11 @@ func runWebhookList(c *Ctx, args []string) int {
9090 ds = append(ds, out{h.ID, h.URL, h.Events, h.Active, h.Secret != ""})
9191 }
9292 return c.emit(ds, func(w io.Writer) {
93 tb := c.table(w, "ID", "URL", "EVENTS")
9394 for _, d := range ds {
94 fmt.Fprintf(w, "%d\t%s\t%s\n", d.ID, d.URL, d.Events)
95 tb.row(cRef(fmt.Sprintf("%d", d.ID)), cText(d.URL), cText(d.Events))
9596 }
97 tb.flush()
9698 })
9799}
98100
@@ -157,13 +159,16 @@ func runWebhookDeliveries(c *Ctx, args []string) int {
157159 rows = append(rows, out{d.ID, d.URL, d.EventKind, d.Status, d.Attempts, d.LastStatus, d.LastError})
158160 }
159161 return c.emit(rows, func(w io.Writer) {
162 tb := c.table(w, "ID", "EVENT", "URL", "STATUS")
160163 for _, d := range rows {
161 extra := ""
164 cells := []cell{cRef(fmt.Sprintf("%d", d.ID)), cText(d.Event), cText(d.URL),
165 cState(fmt.Sprintf("%s (%d attempts)", d.Status, d.Attempts))}
162166 if d.LastError != "" {
163 extra = "\t" + d.LastError
167 cells = append(cells, cText(d.LastError))
164168 }
165 fmt.Fprintf(w, "%d\t%s\t%s\t%s (%d attempts)%s\n", d.ID, d.Event, d.URL, d.Status, d.Attempts, extra)
169 tb.row(cells...)
166170 }
171 tb.flush()
167172 })
168173}
169174
internal/control/wiki.go +3 −1
@@ -96,9 +96,11 @@ func runWikiList(c *Ctx, args []string) int {
9696 d.Pages = []string{}
9797 }
9898 return c.emit(d, func(w io.Writer) {
99 tb := c.table(w, "PAGE")
99100 for _, p := range d.Pages {
100 fmt.Fprintln(w, p)
101 tb.row(cRef(p))
101102 }
103 tb.flush()
102104 })
103105}
104106
internal/sshd/sshd.go +13 −5
@@ -239,6 +239,7 @@ func (s *Server) handleConn(c *conn) {
239239
240240func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) {
241241 defer ch.Close()
242 var term control.Term
242243 for req := range reqs {
243244 switch req.Type {
244245 case "exec":
@@ -267,7 +268,7 @@ func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-cha
267268 }
268269 close(done)
269270 }()
270 code := s.runExec(sconn, ch, payload.Command, done)
271 code := s.runExec(sconn, ch, term, payload.Command, done)
271272 sendExit(ch, code)
272273 return
273274 case "shell":
@@ -275,7 +276,13 @@ func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-cha
275276 fmt.Fprintf(ch, "gitbay control plane: interactive shells are not available.\nTry: ssh %s help\n", s.cfg.Server.SiteURL)
276277 sendExit(ch, protocol.ExitUsage)
277278 return
278 case "pty-req", "env":
279 case "env":
280 var kv struct{ Name, Value string }
281 if ssh.Unmarshal(req.Payload, &kv) == nil && kv.Name == "GITBAY_TERM" {
282 term = control.ParseTerm(kv.Value)
283 }
284 req.Reply(true, nil)
285 case "pty-req":
279286 // Harmless; accept and ignore.
280287 req.Reply(true, nil)
281288 default:
@@ -289,7 +296,7 @@ func sendExit(ch ssh.Channel, code int) {
289296 ch.SendRequest("exit-status", false, ssh.Marshal(&msg))
290297}
291298
292func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string, done <-chan struct{}) int {
299func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, term control.Term, cmdline string, done <-chan struct{}) int {
293300 ext := sconn.Permissions.Extensions
294301 if blob := ext["anon-key"]; blob != "" {
295302 return s.runAnonymous(ch, blob, cmdline)
@@ -302,7 +309,7 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string,
302309 return protocol.ExitDenied
303310 }
304311 _ = s.st.TouchSSHKey(keyID)
305 return Exec(s.cfg, s.st, user, ext["scope"], ext["key-fp"], cmdline, ch, ch, ch.Stderr(), done, s.stopping)
312 return Exec(s.cfg, s.st, user, ext["scope"], ext["key-fp"], term, cmdline, ch, ch, ch.Stderr(), done, s.stopping)
306313}
307314
308315// runAnonymous handles a session from an unregistered key: the register
@@ -332,7 +339,7 @@ func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int {
332339// Exec runs one SSH exec command line for an authenticated key. It is the
333340// single dispatch path shared by the embedded listener and the system-sshd
334341// forced command (gitbayd shell).
335func Exec(cfg config.Config, st *store.Store, user store.User, scope, source, cmdline string,
342func Exec(cfg config.Config, st *store.Store, user store.User, scope, source string, term control.Term, cmdline string,
336343 stdin io.Reader, stdout, stderr io.Writer, done, stopping <-chan struct{}) int {
337344 if user.Disabled {
338345 fmt.Fprintln(stderr, "this account is disabled; contact the instance admin")
@@ -365,6 +372,7 @@ func Exec(cfg config.Config, st *store.Store, user store.User, scope, source, cm
365372 User: user,
366373 Scope: scope,
367374 Source: source,
375 Term: term,
368376 Store: st,
369377 Cfg: cfg,
370378 Stdin: stdin,