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.
66
77* Unreleased
88
9Credentials: revocation and delegation (#256, #257).
9Credentials: revocation, delegation and expiry (#256, #257, #277).
1010
1111*Upgrade note.* =token create= makes a =read= token unless given
1212=--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.
2525 15 seconds. An expiring key cannot create credentials, like an
2626 expiring token. =keys list= and =repo deploy-key list= gain =USED= and
2727 =EXPIRES= columns, after the label (#277).
28- =token list= at a terminal shows a future expiry as a time, not
29 "just now" (#286).
2830
2931* v1.36.0 — 2026-09-23
3032
internal/control/deploykey.go +1 −1
@@ -84,7 +84,7 @@ func runDeployKeyAdd(c *Ctx, args []string) int {
8484 return c.emit(d, func(w io.Writer) {
8585 line := fmt.Sprintf("deploy key %s (%s) bound to %s", fp, mode, repo.Path())
8686 if expires != nil {
87 line += ", " + expiresText(expires, time.Now())
87 line += ", expires " + expiresText(expires, time.Now())
8888 }
8989 fmt.Fprintln(w, line)
9090 })
internal/control/identity.go +9 −9
@@ -127,28 +127,28 @@ func keyLabel(s string) (string, error) {
127127 return s, nil
128128}
129129
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.
131131func (c *Ctx) usedText(ts string) string {
132132 switch {
133133 case ts == "":
134 return "never used"
134 return "never"
135135 case c.Term.Cols == 0:
136 return "used " + stamp(ts)
136 return stamp(ts)
137137 }
138 return "used " + relAge(ts, termNow())
138 return relAge(ts, termNow())
139139}
140140
141// expiresText is a credential's expiry as a list shows it. It is
142// absolute at a terminal too: relAge reads only the past.
141// expiresText is a credential's expiry as an EXPIRES cell shows it. It
142// is absolute at a terminal too: relAge reads only the past.
143143func expiresText(t *time.Time, now time.Time) string {
144144 if t == nil {
145 return "never expires"
145 return "never"
146146 }
147147 s := stamp(t.UTC().Format(time.RFC3339Nano))
148148 if !t.After(now) {
149149 return "expired " + s
150150 }
151 return "expires " + s
151 return s
152152}
153153
154154func runKeysAdd(c *Ctx, args []string) int {
@@ -204,7 +204,7 @@ func runKeysAdd(c *Ctx, args []string) int {
204204 line += " " + d.Label
205205 }
206206 if d.ExpiresAt != nil {
207 line += ", " + expiresText(d.ExpiresAt, time.Now())
207 line += ", expires " + expiresText(d.ExpiresAt, time.Now())
208208 }
209209 fmt.Fprintln(w, line)
210210 })
internal/control/keyexpiry_test.go +6 −2
@@ -63,12 +63,16 @@ func TestKeysAddTTLAndList(t *testing.T) {
6363 t.Errorf("%s expires %v", k.Label, k.ExpiresAt)
6464 }
6565 }
66 exp := map[string]string{}
67 for _, k := range keys {
68 exp[k.Label] = k.ExpiresAt.UTC().Format("2006-01-02")
69 }
6670 out, _, _ := run("", "keys", "list")
67 if !strings.Contains(out, "\tlaptop\tnever used\texpires ") {
71 if !strings.Contains(out, "\tlaptop\tnever\t"+exp["laptop"]) {
6872 t.Fatalf("keys list:\n%s", out)
6973 }
7074 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"]) {
7276 t.Fatalf("deploy-key list:\n%s", out)
7377 }
7478}
internal/control/table.go +1 −1
@@ -95,7 +95,7 @@ func (t *table) flush() {
9595 line := make([]string, n)
9696 for i := range line {
9797 if i < len(t.header) {
98 line[i] = t.header[i]
98 line[i] = clip(t.header[i], widths[i])
9999 }
100100 }
101101 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) {
3535 }
3636}
3737
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
3854func TestTableColourOnlyAddsSGR(t *testing.T) {
3955 var mono, colour bytes.Buffer
4056 fixtureTable(&Ctx{Term: Term{Cols: 40}}, &mono)
internal/control/token_test.go +1 −1
@@ -64,7 +64,7 @@ func TestTokenListFutureExpiryAtTerminal(t *testing.T) {
6464 if strings.Contains(out.String(), "just now") {
6565 t.Fatalf("token list at a terminal printed \"just now\" for a future expiry:\n%s", out.String())
6666 }
67 if !strings.Contains(out.String(), "expires ") {
67 if !strings.Contains(out.String(), exp.UTC().Format("2006-01-02")) {
6868 t.Fatalf("token list:\n%s", out.String())
6969 }
7070}