Commit 32a5f76e5b

32a5f76e5b270097b63a5bba9a43557cf50ad63d

parent: 2ddf2389c0

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 08:17 UTC

control: key lists fit 60 columns; headers clip with their column

USED and EXPIRES cells drop the words their headers carry and print
never for no value. A header wider than its fitted column is clipped
like the cells under it.

Ref #277

Layout: unified · split

CHANGELOG.org +3 −1
@@ -6,7 +6,7 @@ anything beyond "replace the binary and restart" is needed.
6 6
7* Unreleased 7* Unreleased
8 8
9Credentials: revocation and delegation (#256, #257). 9Credentials: revocation, delegation and expiry (#256, #257, #277).
10 10
11*Upgrade note.* =token create= makes a =read= token unless given 11*Upgrade note.* =token create= makes a =read= token unless given
12=--scope full=. A script that mints a token and then writes with it 12=--scope full=. A script that mints a token and then writes with it
@@ -25,6 +25,8 @@ must add =--scope full=. Existing tokens keep their scope.
25 15 seconds. An expiring key cannot create credentials, like an 25 15 seconds. An expiring key cannot create credentials, like an
26 expiring token. =keys list= and =repo deploy-key list= gain =USED= and 26 expiring token. =keys list= and =repo deploy-key list= gain =USED= and
27 =EXPIRES= columns, after the label (#277). 27 =EXPIRES= columns, after the label (#277).
28- =token list= at a terminal shows a future expiry as a time, not
29 "just now" (#286).
28 30
29* v1.36.0 — 2026-09-23 31* v1.36.0 — 2026-09-23
30 32
internal/control/deploykey.go +1 −1
@@ -84,7 +84,7 @@ func runDeployKeyAdd(c *Ctx, args []string) int {
84 return c.emit(d, func(w io.Writer) { 84 return c.emit(d, func(w io.Writer) {
85 line := fmt.Sprintf("deploy key %s (%s) bound to %s", fp, mode, repo.Path()) 85 line := fmt.Sprintf("deploy key %s (%s) bound to %s", fp, mode, repo.Path())
86 if expires != nil { 86 if expires != nil {
87 line += ", " + expiresText(expires, time.Now()) 87 line += ", expires " + expiresText(expires, time.Now())
88 } 88 }
89 fmt.Fprintln(w, line) 89 fmt.Fprintln(w, line)
90 }) 90 })
internal/control/identity.go +9 −9
@@ -127,28 +127,28 @@ func keyLabel(s string) (string, error) {
127 return s, nil 127 return s, nil
128} 128}
129 129
130// usedText is a key's last use as a list shows it. 130// usedText is a key's last use as a USED cell shows it.
131func (c *Ctx) usedText(ts string) string { 131func (c *Ctx) usedText(ts string) string {
132 switch { 132 switch {
133 case ts == "": 133 case ts == "":
134 return "never used" 134 return "never"
135 case c.Term.Cols == 0: 135 case c.Term.Cols == 0:
136 return "used " + stamp(ts) 136 return stamp(ts)
137 } 137 }
138 return "used " + relAge(ts, termNow()) 138 return relAge(ts, termNow())
139} 139}
140 140
141// expiresText is a credential's expiry as a list shows it. It is 141// expiresText is a credential's expiry as an EXPIRES cell shows it. It
142// absolute at a terminal too: relAge reads only the past. 142// is absolute at a terminal too: relAge reads only the past.
143func expiresText(t *time.Time, now time.Time) string { 143func expiresText(t *time.Time, now time.Time) string {
144 if t == nil { 144 if t == nil {
145 return "never expires" 145 return "never"
146 } 146 }
147 s := stamp(t.UTC().Format(time.RFC3339Nano)) 147 s := stamp(t.UTC().Format(time.RFC3339Nano))
148 if !t.After(now) { 148 if !t.After(now) {
149 return "expired " + s 149 return "expired " + s
150 } 150 }
151 return "expires " + s 151 return s
152} 152}
153 153
154func runKeysAdd(c *Ctx, args []string) int { 154func runKeysAdd(c *Ctx, args []string) int {
@@ -204,7 +204,7 @@ func runKeysAdd(c *Ctx, args []string) int {
204 line += " " + d.Label 204 line += " " + d.Label
205 } 205 }
206 if d.ExpiresAt != nil { 206 if d.ExpiresAt != nil {
207 line += ", " + expiresText(d.ExpiresAt, time.Now()) 207 line += ", expires " + expiresText(d.ExpiresAt, time.Now())
208 } 208 }
209 fmt.Fprintln(w, line) 209 fmt.Fprintln(w, line)
210 }) 210 })
internal/control/keyexpiry_test.go +6 −2
@@ -63,12 +63,16 @@ func TestKeysAddTTLAndList(t *testing.T) {
63 t.Errorf("%s expires %v", k.Label, k.ExpiresAt) 63 t.Errorf("%s expires %v", k.Label, k.ExpiresAt)
64 } 64 }
65 } 65 }
66 exp := map[string]string{}
67 for _, k := range keys {
68 exp[k.Label] = k.ExpiresAt.UTC().Format("2006-01-02")
69 }
66 out, _, _ := run("", "keys", "list") 70 out, _, _ := run("", "keys", "list")
67 if !strings.Contains(out, "\tlaptop\tnever used\texpires ") { 71 if !strings.Contains(out, "\tlaptop\tnever\t"+exp["laptop"]) {
68 t.Fatalf("keys list:\n%s", out) 72 t.Fatalf("keys list:\n%s", out)
69 } 73 }
70 out, _, _ = run("", "repo", "deploy-key", "list", repo.Path()) 74 out, _, _ = run("", "repo", "deploy-key", "list", repo.Path())
71 if !strings.Contains(out, "\tci\tnever used\texpires ") { 75 if !strings.Contains(out, "\tci\tnever\t"+exp["ci"]) {
72 t.Fatalf("deploy-key list:\n%s", out) 76 t.Fatalf("deploy-key list:\n%s", out)
73 } 77 }
74} 78}
internal/control/table.go +1 −1
@@ -95,7 +95,7 @@ func (t *table) flush() {
95 line := make([]string, n) 95 line := make([]string, n)
96 for i := range line { 96 for i := range line {
97 if i < len(t.header) { 97 if i < len(t.header) {
98 line[i] = t.header[i] 98 line[i] = clip(t.header[i], widths[i])
99 } 99 }
100 } 100 }
101 b.WriteString(t.term.paint(sgrDim, t.join(line, widths)) + "\n") 101 b.WriteString(t.term.paint(sgrDim, t.join(line, widths)) + "\n")
internal/control/table_test.go +16
@@ -35,6 +35,22 @@ func TestTableTerminalFits(t *testing.T) {
35 } 35 }
36} 36}
37 37
38// A column shrunk below its header's width clips the header too.
39func TestTableClipsHeaderToColumn(t *testing.T) {
40 var b bytes.Buffer
41 tb := (&Ctx{Term: Term{Cols: 16}}).table(&b, "FINGERPRINT", "SCOPE")
42 tb.row(cFlex("SHA256:abcdefghijklmnopqrstuvwxyz"), cState("full"))
43 tb.flush()
44 for _, line := range strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n") {
45 if cells(line) > 16 {
46 t.Errorf("line of %d cells at 16 columns: %q", cells(line), line)
47 }
48 }
49 if !strings.HasPrefix(b.String(), "FINGERPR… SCOPE\n") {
50 t.Errorf("header:\n%s", b.String())
51 }
52}
53
38func TestTableColourOnlyAddsSGR(t *testing.T) { 54func TestTableColourOnlyAddsSGR(t *testing.T) {
39 var mono, colour bytes.Buffer 55 var mono, colour bytes.Buffer
40 fixtureTable(&Ctx{Term: Term{Cols: 40}}, &mono) 56 fixtureTable(&Ctx{Term: Term{Cols: 40}}, &mono)
internal/control/token_test.go +1 −1
@@ -64,7 +64,7 @@ func TestTokenListFutureExpiryAtTerminal(t *testing.T) {
64 if strings.Contains(out.String(), "just now") { 64 if strings.Contains(out.String(), "just now") {
65 t.Fatalf("token list at a terminal printed \"just now\" for a future expiry:\n%s", out.String()) 65 t.Fatalf("token list at a terminal printed \"just now\" for a future expiry:\n%s", out.String())
66 } 66 }
67 if !strings.Contains(out.String(), "expires ") { 67 if !strings.Contains(out.String(), exp.UTC().Format("2006-01-02")) {
68 t.Fatalf("token list:\n%s", out.String()) 68 t.Fatalf("token list:\n%s", out.String())
69 } 69 }
70} 70}