detached cd1e37d

feat(cli): add repo, token, version and watch commands

commit
cd1e37dc616adc92f681bca07b97220b346495cc
author
brice · · 2026-10-10 08:50 UTC
committer
brice · · 2026-10-10 09:42 UTC
parent
46d89a8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +2112 -23
A cmd/ermite/cli.go +162 -0
@@ -0,0 +1,162 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "os"
11+ "strings"
12+ "text/tabwriter"
13+ "time"
14+
15+ "github.com/bnema/zerowrap"
16+ "github.com/spf13/cobra"
17+ "google.golang.org/protobuf/types/known/timestamppb"
18+
19+ "bnema.dev/ermite/internal/adapters/client"
20+ "bnema.dev/ermite/internal/config"
21+)
22+
23+// cliEnv is what the remote commands read from the outside: the environment,
24+// the clock and the build version. Tests replace it.
25+type cliEnv struct {
26+ getenv func(string) string
27+ now func() time.Time
28+ version string // bare build version, for example "v0.1.0" or "(devel)"
29+ // backoff and sleep tune the reconnections of watch; zero values mean the
30+ // defaults.
31+ backoff client.Backoff
32+ sleep func(context.Context, time.Duration) error
33+}
34+
35+func defaultCLIEnv() *cliEnv {
36+ return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion()}
37+}
38+
39+// userAgent identifies this CLI to the server.
40+func (e *cliEnv) userAgent() string {
41+ return "ermite/" + strings.Trim(e.version, "()") + " (cli)"
42+}
43+
44+// ctx is the command context carrying a logger when ERMITE_LOG_LEVEL is set:
45+// the remote commands log nothing by default (their output is the product).
46+func (e *cliEnv) ctx(cmd *cobra.Command) context.Context {
47+ ctx := cmd.Context()
48+ if ctx == nil {
49+ ctx = context.Background()
50+ }
51+ if lvl := e.getenv(config.EnvLogLevel); lvl != "" {
52+ ctx = zerowrap.WithCtx(ctx, zerowrap.New(zerowrap.Config{Level: lvl, Format: "console", Output: cmd.ErrOrStderr()}))
53+ }
54+ return ctx
55+}
56+
57+// config reads and validates ERMITE_URL and ERMITE_TOKEN.
58+func (e *cliEnv) config() (client.Config, error) {
59+ cfg, err := client.ConfigFromEnv(e.getenv)
60+ if err != nil {
61+ return cfg, err
62+ }
63+ cfg.UserAgent = e.userAgent()
64+ return cfg, nil
65+}
66+
67+// client builds the API client. Commands warn when the token would travel in
68+// the clear; completion stays silent.
69+func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) {
70+ cfg, err := e.config()
71+ if err != nil {
72+ return nil, err
73+ }
74+ if w := cfg.InsecureTokenWarning(); w != "" && warn {
75+ warnf(cmd, "%s", w)
76+ }
77+ return client.New(cfg)
78+}
79+
80+// warnf prints "ermite: warning: …" on stderr.
81+func warnf(cmd *cobra.Command, format string, args ...any) {
82+ _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ermite: warning: %s\n", fmt.Sprintf(format, args...))
83+}
84+
85+// noticef prints "ermite: …" on stderr.
86+func noticef(cmd *cobra.Command, format string, args ...any) {
87+ _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ermite: %s\n", fmt.Sprintf(format, args...))
88+}
89+
90+// addJSONFlag registers --json and returns its value.
91+func addJSONFlag(cmd *cobra.Command) *bool {
92+ return cmd.Flags().Bool("json", false, "print machine-readable JSON (fields are listed in --help)")
93+}
94+
95+// writeNDJSON prints v as one compact JSON line.
96+func writeNDJSON(w io.Writer, v any) error {
97+ return json.NewEncoder(w).Encode(v)
98+}
99+
100+// writeJSON prints v as indented JSON.
101+func writeJSON(w io.Writer, v any) error {
102+ enc := json.NewEncoder(w)
103+ enc.SetIndent("", " ")
104+ return enc.Encode(v)
105+}
106+
107+// clean makes a server-provided string safe for a terminal: control
108+// characters (escape sequences, line breaks) are replaced.
109+func clean(s string) string {
110+ return strings.Map(func(r rune) rune {
111+ switch {
112+ case r == '\t' || r == '\n' || r == '\r':
113+ return ' '
114+ case r < 0x20 || r == 0x7f || (r >= 0x80 && r < 0xa0):
115+ return '?'
116+ }
117+ return r
118+ }, s)
119+}
120+
121+// table prints aligned columns under upper-case headers.
122+func table(w io.Writer, header []string, rows [][]string) error {
123+ tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
124+ _, _ = fmt.Fprintln(tw, strings.Join(header, "\t"))
125+ for _, r := range rows {
126+ cells := make([]string, len(r))
127+ for i, c := range r {
128+ cells[i] = clean(c)
129+ }
130+ _, _ = fmt.Fprintln(tw, strings.Join(cells, "\t"))
131+ }
132+ return tw.Flush()
133+}
134+
135+// fields prints "name value" pairs aligned on the names.
136+func fields(w io.Writer, pairs [][2]string) error {
137+ tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
138+ for _, p := range pairs {
139+ _, _ = fmt.Fprintf(tw, "%s\t%s\n", p[0], clean(p[1]))
140+ }
141+ return tw.Flush()
142+}
143+
144+// timeFormat is how tables show instants, in UTC.
145+const timeFormat = "2006-01-02 15:04"
146+
147+// jsonTime is an RFC 3339 UTC string, or nil (JSON null) for an unset timestamp.
148+func jsonTime(t *timestamppb.Timestamp) *string {
149+ if !t.IsValid() {
150+ return nil
151+ }
152+ s := t.AsTime().UTC().Format(time.RFC3339)
153+ return &s
154+}
155+
156+// shortTime is a table cell for a timestamp, or fallback when unset.
157+func shortTime(t *timestamppb.Timestamp, fallback string) string {
158+ if !t.IsValid() {
159+ return fallback
160+ }
161+ return t.AsTime().UTC().Format(timeFormat)
162+}
@@ -0,0 +1,162 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "os"
11+ "strings"
12+ "text/tabwriter"
13+ "time"
14+
15+ "github.com/bnema/zerowrap"
16+ "github.com/spf13/cobra"
17+ "google.golang.org/protobuf/types/known/timestamppb"
18+
19+ "bnema.dev/ermite/internal/adapters/client"
20+ "bnema.dev/ermite/internal/config"
21+)
22+
23+// cliEnv is what the remote commands read from the outside: the environment,
24+// the clock and the build version. Tests replace it.
25+type cliEnv struct {
26+ getenv func(string) string
27+ now func() time.Time
28+ version string // bare build version, for example "v0.1.0" or "(devel)"
29+ // backoff and sleep tune the reconnections of watch; zero values mean the
30+ // defaults.
31+ backoff client.Backoff
32+ sleep func(context.Context, time.Duration) error
33+}
34+
35+func defaultCLIEnv() *cliEnv {
36+ return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion()}
37+}
38+
39+// userAgent identifies this CLI to the server.
40+func (e *cliEnv) userAgent() string {
41+ return "ermite/" + strings.Trim(e.version, "()") + " (cli)"
42+}
43+
44+// ctx is the command context carrying a logger when ERMITE_LOG_LEVEL is set:
45+// the remote commands log nothing by default (their output is the product).
46+func (e *cliEnv) ctx(cmd *cobra.Command) context.Context {
47+ ctx := cmd.Context()
48+ if ctx == nil {
49+ ctx = context.Background()
50+ }
51+ if lvl := e.getenv(config.EnvLogLevel); lvl != "" {
52+ ctx = zerowrap.WithCtx(ctx, zerowrap.New(zerowrap.Config{Level: lvl, Format: "console", Output: cmd.ErrOrStderr()}))
53+ }
54+ return ctx
55+}
56+
57+// config reads and validates ERMITE_URL and ERMITE_TOKEN.
58+func (e *cliEnv) config() (client.Config, error) {
59+ cfg, err := client.ConfigFromEnv(e.getenv)
60+ if err != nil {
61+ return cfg, err
62+ }
63+ cfg.UserAgent = e.userAgent()
64+ return cfg, nil
65+}
66+
67+// client builds the API client. Commands warn when the token would travel in
68+// the clear; completion stays silent.
69+func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) {
70+ cfg, err := e.config()
71+ if err != nil {
72+ return nil, err
73+ }
74+ if w := cfg.InsecureTokenWarning(); w != "" && warn {
75+ warnf(cmd, "%s", w)
76+ }
77+ return client.New(cfg)
78+}
79+
80+// warnf prints "ermite: warning: …" on stderr.
81+func warnf(cmd *cobra.Command, format string, args ...any) {
82+ _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ermite: warning: %s\n", fmt.Sprintf(format, args...))
83+}
84+
85+// noticef prints "ermite: …" on stderr.
86+func noticef(cmd *cobra.Command, format string, args ...any) {
87+ _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ermite: %s\n", fmt.Sprintf(format, args...))
88+}
89+
90+// addJSONFlag registers --json and returns its value.
91+func addJSONFlag(cmd *cobra.Command) *bool {
92+ return cmd.Flags().Bool("json", false, "print machine-readable JSON (fields are listed in --help)")
93+}
94+
95+// writeNDJSON prints v as one compact JSON line.
96+func writeNDJSON(w io.Writer, v any) error {
97+ return json.NewEncoder(w).Encode(v)
98+}
99+
100+// writeJSON prints v as indented JSON.
101+func writeJSON(w io.Writer, v any) error {
102+ enc := json.NewEncoder(w)
103+ enc.SetIndent("", " ")
104+ return enc.Encode(v)
105+}
106+
107+// clean makes a server-provided string safe for a terminal: control
108+// characters (escape sequences, line breaks) are replaced.
109+func clean(s string) string {
110+ return strings.Map(func(r rune) rune {
111+ switch {
112+ case r == '\t' || r == '\n' || r == '\r':
113+ return ' '
114+ case r < 0x20 || r == 0x7f || (r >= 0x80 && r < 0xa0):
115+ return '?'
116+ }
117+ return r
118+ }, s)
119+}
120+
121+// table prints aligned columns under upper-case headers.
122+func table(w io.Writer, header []string, rows [][]string) error {
123+ tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
124+ _, _ = fmt.Fprintln(tw, strings.Join(header, "\t"))
125+ for _, r := range rows {
126+ cells := make([]string, len(r))
127+ for i, c := range r {
128+ cells[i] = clean(c)
129+ }
130+ _, _ = fmt.Fprintln(tw, strings.Join(cells, "\t"))
131+ }
132+ return tw.Flush()
133+}
134+
135+// fields prints "name value" pairs aligned on the names.
136+func fields(w io.Writer, pairs [][2]string) error {
137+ tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
138+ for _, p := range pairs {
139+ _, _ = fmt.Fprintf(tw, "%s\t%s\n", p[0], clean(p[1]))
140+ }
141+ return tw.Flush()
142+}
143+
144+// timeFormat is how tables show instants, in UTC.
145+const timeFormat = "2006-01-02 15:04"
146+
147+// jsonTime is an RFC 3339 UTC string, or nil (JSON null) for an unset timestamp.
148+func jsonTime(t *timestamppb.Timestamp) *string {
149+ if !t.IsValid() {
150+ return nil
151+ }
152+ s := t.AsTime().UTC().Format(time.RFC3339)
153+ return &s
154+}
155+
156+// shortTime is a table cell for a timestamp, or fallback when unset.
157+func shortTime(t *timestamppb.Timestamp, fallback string) string {
158+ if !t.IsValid() {
159+ return fallback
160+ }
161+ return t.AsTime().UTC().Format(timeFormat)
162+}
A cmd/ermite/cli_binary_test.go +54 -0
@@ -0,0 +1,54 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "os/exec"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite-api/gen/ermite"
15+)
16+
17+// runBinary runs the built ermite with only the given variables.
18+func runBinary(t *testing.T, vars []string, args ...string) (stdout, stderr string, code int) {
19+ t.Helper()
20+ cmd := exec.Command(binary(t), args...)
21+ cmd.Env = append(baseEnv(), vars...)
22+ var out, errb bytes.Buffer
23+ cmd.Stdout, cmd.Stderr = &out, &errb
24+ err := cmd.Run()
25+ var exit *exec.ExitError
26+ if errors.As(err, &exit) {
27+ code = exit.ExitCode()
28+ } else {
29+ require.NoError(t, err)
30+ }
31+ return out.String(), errb.String(), code
32+}
33+
34+func TestBinaryRemoteCommandErrorIsOneLineAndExitsOne(t *testing.T) {
35+ _, stderr, code := runBinary(t, nil, "repo", "list")
36+ assert.Equal(t, 1, code)
37+ assert.Regexp(t, `^ermite: ERMITE_URL is not set[^\n]*\n$`, stderr)
38+}
39+
40+func TestBinaryAgainstStubServer(t *testing.T) {
41+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
42+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
43+ }}
44+ url := f.serve(t)
45+ stdout, stderr, code := runBinary(t, []string{"ERMITE_URL=" + url, "ERMITE_TOKEN=ermite_t"}, "repo", "list", "--json")
46+ assert.Equal(t, 0, code, stderr)
47+ assert.Contains(t, stdout, `"name": "alpha"`)
48+ assert.Contains(t, f.header[0].Get("User-Agent"), "ermite/")
49+ assert.Contains(t, f.header[0].Get("User-Agent"), "(cli)")
50+
51+ _, stderr, code = runBinary(t, []string{"ERMITE_URL=" + url}, "repo", "delete", "alpha")
52+ assert.Equal(t, 1, code)
53+ assert.Contains(t, stderr, "ermite: aborted")
54+}
@@ -0,0 +1,54 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "os/exec"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite-api/gen/ermite"
15+)
16+
17+// runBinary runs the built ermite with only the given variables.
18+func runBinary(t *testing.T, vars []string, args ...string) (stdout, stderr string, code int) {
19+ t.Helper()
20+ cmd := exec.Command(binary(t), args...)
21+ cmd.Env = append(baseEnv(), vars...)
22+ var out, errb bytes.Buffer
23+ cmd.Stdout, cmd.Stderr = &out, &errb
24+ err := cmd.Run()
25+ var exit *exec.ExitError
26+ if errors.As(err, &exit) {
27+ code = exit.ExitCode()
28+ } else {
29+ require.NoError(t, err)
30+ }
31+ return out.String(), errb.String(), code
32+}
33+
34+func TestBinaryRemoteCommandErrorIsOneLineAndExitsOne(t *testing.T) {
35+ _, stderr, code := runBinary(t, nil, "repo", "list")
36+ assert.Equal(t, 1, code)
37+ assert.Regexp(t, `^ermite: ERMITE_URL is not set[^\n]*\n$`, stderr)
38+}
39+
40+func TestBinaryAgainstStubServer(t *testing.T) {
41+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
42+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
43+ }}
44+ url := f.serve(t)
45+ stdout, stderr, code := runBinary(t, []string{"ERMITE_URL=" + url, "ERMITE_TOKEN=ermite_t"}, "repo", "list", "--json")
46+ assert.Equal(t, 0, code, stderr)
47+ assert.Contains(t, stdout, `"name": "alpha"`)
48+ assert.Contains(t, f.header[0].Get("User-Agent"), "ermite/")
49+ assert.Contains(t, f.header[0].Get("User-Agent"), "(cli)")
50+
51+ _, stderr, code = runBinary(t, []string{"ERMITE_URL=" + url}, "repo", "delete", "alpha")
52+ assert.Equal(t, 1, code)
53+ assert.Contains(t, stderr, "ermite: aborted")
54+}
A cmd/ermite/cli_fake_test.go +169 -0
@@ -0,0 +1,169 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "bytes"
7+ "context"
8+ "net/http"
9+ "net/http/httptest"
10+ "strings"
11+ "sync"
12+ "testing"
13+ "time"
14+
15+ "connectrpc.com/connect"
16+ "github.com/stretchr/testify/require"
17+ "google.golang.org/protobuf/types/known/timestamppb"
18+
19+ "bnema.dev/ermite-api/gen/ermite"
20+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
21+)
22+
23+// fakeAPI is a Connect server with stub services: each field, when set,
24+// answers the matching RPC; the others answer unimplemented. It records the
25+// requests it receives.
26+type fakeAPI struct {
27+ ermiteconnect.UnimplementedRepoServiceHandler
28+ ermiteconnect.UnimplementedTokenServiceHandler
29+ ermiteconnect.UnimplementedServerServiceHandler
30+ ermiteconnect.UnimplementedEventServiceHandler
31+
32+ mu sync.Mutex
33+ calls []string // procedure names, in order
34+ reqs []any // request messages, in order
35+ header []http.Header // request headers, in order
36+
37+ listRepos func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error)
38+ getRepo func(*ermite.GetRepoRequest) (*ermite.GetRepoResponse, error)
39+ createRepo func(*ermite.CreateRepoRequest) (*ermite.CreateRepoResponse, error)
40+ updateRepo func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error)
41+ deleteRepo func(*ermite.DeleteRepoRequest) (*ermite.DeleteRepoResponse, error)
42+ createToken func(*ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error)
43+ listTokens func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error)
44+ revokeToken func(*ermite.RevokeTokenRequest) (*ermite.RevokeTokenResponse, error)
45+ getVersion func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error)
46+ watch func(context.Context, *ermite.WatchRequest, *connect.ServerStream[ermite.WatchResponse]) error
47+}
48+
49+func (f *fakeAPI) record(name string, h http.Header, req any) {
50+ f.mu.Lock()
51+ defer f.mu.Unlock()
52+ f.calls = append(f.calls, name)
53+ f.reqs = append(f.reqs, req)
54+ f.header = append(f.header, h.Clone())
55+}
56+
57+func (f *fakeAPI) snapshot() (calls []string, reqs []any) {
58+ f.mu.Lock()
59+ defer f.mu.Unlock()
60+ return append([]string(nil), f.calls...), append([]any(nil), f.reqs...)
61+}
62+
63+func respond[Req, Res any](f *fakeAPI, name string, req *connect.Request[Req], fn func(*Req) (*Res, error)) (*connect.Response[Res], error) {
64+ f.record(name, req.Header(), req.Msg)
65+ if fn == nil {
66+ return nil, connect.NewError(connect.CodeUnimplemented, nil)
67+ }
68+ res, err := fn(req.Msg)
69+ if err != nil {
70+ return nil, err
71+ }
72+ return connect.NewResponse(res), nil
73+}
74+
75+func (f *fakeAPI) ListRepos(_ context.Context, r *connect.Request[ermite.ListReposRequest]) (*connect.Response[ermite.ListReposResponse], error) {
76+ return respond(f, "ListRepos", r, f.listRepos)
77+}
78+func (f *fakeAPI) GetRepo(_ context.Context, r *connect.Request[ermite.GetRepoRequest]) (*connect.Response[ermite.GetRepoResponse], error) {
79+ return respond(f, "GetRepo", r, f.getRepo)
80+}
81+func (f *fakeAPI) CreateRepo(_ context.Context, r *connect.Request[ermite.CreateRepoRequest]) (*connect.Response[ermite.CreateRepoResponse], error) {
82+ return respond(f, "CreateRepo", r, f.createRepo)
83+}
84+func (f *fakeAPI) UpdateRepo(_ context.Context, r *connect.Request[ermite.UpdateRepoRequest]) (*connect.Response[ermite.UpdateRepoResponse], error) {
85+ return respond(f, "UpdateRepo", r, f.updateRepo)
86+}
87+func (f *fakeAPI) DeleteRepo(_ context.Context, r *connect.Request[ermite.DeleteRepoRequest]) (*connect.Response[ermite.DeleteRepoResponse], error) {
88+ return respond(f, "DeleteRepo", r, f.deleteRepo)
89+}
90+func (f *fakeAPI) CreateToken(_ context.Context, r *connect.Request[ermite.CreateTokenRequest]) (*connect.Response[ermite.CreateTokenResponse], error) {
91+ return respond(f, "CreateToken", r, f.createToken)
92+}
93+func (f *fakeAPI) ListTokens(_ context.Context, r *connect.Request[ermite.ListTokensRequest]) (*connect.Response[ermite.ListTokensResponse], error) {
94+ return respond(f, "ListTokens", r, f.listTokens)
95+}
96+func (f *fakeAPI) RevokeToken(_ context.Context, r *connect.Request[ermite.RevokeTokenRequest]) (*connect.Response[ermite.RevokeTokenResponse], error) {
97+ return respond(f, "RevokeToken", r, f.revokeToken)
98+}
99+func (f *fakeAPI) GetVersion(_ context.Context, r *connect.Request[ermite.GetVersionRequest]) (*connect.Response[ermite.GetVersionResponse], error) {
100+ return respond(f, "GetVersion", r, f.getVersion)
101+}
102+func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error {
103+ f.record("Watch", r.Header(), r.Msg)
104+ if f.watch == nil {
105+ return connect.NewError(connect.CodeUnimplemented, nil)
106+ }
107+ return f.watch(ctx, r.Msg, st)
108+}
109+
110+// serve starts the fake under /-/api and returns its base URL.
111+func (f *fakeAPI) serve(t *testing.T) string {
112+ t.Helper()
113+ mux := http.NewServeMux()
114+ mount := func(path string, h http.Handler) { mux.Handle("/-/api"+path, http.StripPrefix("/-/api", h)) }
115+ p, h := ermiteconnect.NewRepoServiceHandler(f)
116+ mount(p, h)
117+ p, h = ermiteconnect.NewTokenServiceHandler(f)
118+ mount(p, h)
119+ p, h = ermiteconnect.NewServerServiceHandler(f)
120+ mount(p, h)
121+ p, h = ermiteconnect.NewEventServiceHandler(f)
122+ mount(p, h)
123+ srv := httptest.NewServer(mux)
124+ t.Cleanup(srv.Close)
125+ return srv.URL
126+}
127+
128+var testNow = time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)
129+
130+// result is the outcome of one CLI run.
131+type result struct {
132+ stdout, stderr string
133+ err error
134+}
135+
136+// runCLI runs the command tree in process with the given environment.
137+func runCLI(t *testing.T, vars map[string]string, stdin string, args ...string) result {
138+ t.Helper()
139+ env := &cliEnv{
140+ getenv: func(k string) string { return vars[k] },
141+ now: func() time.Time { return testNow },
142+ version: "v0.0.1",
143+ sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() },
144+ }
145+ root := newRootCmdWith(env)
146+ var out, errb bytes.Buffer
147+ root.SetOut(&out)
148+ root.SetErr(&errb)
149+ root.SetIn(strings.NewReader(stdin))
150+ root.SetArgs(args)
151+ ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
152+ defer cancel()
153+ err := root.ExecuteContext(ctx)
154+ return result{stdout: out.String(), stderr: errb.String(), err: err}
155+}
156+
157+func (r result) ok(t *testing.T) result {
158+ t.Helper()
159+ require.NoError(t, r.err, "stderr: %s", r.stderr)
160+ return r
161+}
162+
163+func ts(s string) *timestamppb.Timestamp {
164+ v, err := time.Parse(time.RFC3339, s)
165+ if err != nil {
166+ panic(err)
167+ }
168+ return timestamppb.New(v)
169+}
@@ -0,0 +1,169 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "bytes"
7+ "context"
8+ "net/http"
9+ "net/http/httptest"
10+ "strings"
11+ "sync"
12+ "testing"
13+ "time"
14+
15+ "connectrpc.com/connect"
16+ "github.com/stretchr/testify/require"
17+ "google.golang.org/protobuf/types/known/timestamppb"
18+
19+ "bnema.dev/ermite-api/gen/ermite"
20+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
21+)
22+
23+// fakeAPI is a Connect server with stub services: each field, when set,
24+// answers the matching RPC; the others answer unimplemented. It records the
25+// requests it receives.
26+type fakeAPI struct {
27+ ermiteconnect.UnimplementedRepoServiceHandler
28+ ermiteconnect.UnimplementedTokenServiceHandler
29+ ermiteconnect.UnimplementedServerServiceHandler
30+ ermiteconnect.UnimplementedEventServiceHandler
31+
32+ mu sync.Mutex
33+ calls []string // procedure names, in order
34+ reqs []any // request messages, in order
35+ header []http.Header // request headers, in order
36+
37+ listRepos func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error)
38+ getRepo func(*ermite.GetRepoRequest) (*ermite.GetRepoResponse, error)
39+ createRepo func(*ermite.CreateRepoRequest) (*ermite.CreateRepoResponse, error)
40+ updateRepo func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error)
41+ deleteRepo func(*ermite.DeleteRepoRequest) (*ermite.DeleteRepoResponse, error)
42+ createToken func(*ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error)
43+ listTokens func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error)
44+ revokeToken func(*ermite.RevokeTokenRequest) (*ermite.RevokeTokenResponse, error)
45+ getVersion func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error)
46+ watch func(context.Context, *ermite.WatchRequest, *connect.ServerStream[ermite.WatchResponse]) error
47+}
48+
49+func (f *fakeAPI) record(name string, h http.Header, req any) {
50+ f.mu.Lock()
51+ defer f.mu.Unlock()
52+ f.calls = append(f.calls, name)
53+ f.reqs = append(f.reqs, req)
54+ f.header = append(f.header, h.Clone())
55+}
56+
57+func (f *fakeAPI) snapshot() (calls []string, reqs []any) {
58+ f.mu.Lock()
59+ defer f.mu.Unlock()
60+ return append([]string(nil), f.calls...), append([]any(nil), f.reqs...)
61+}
62+
63+func respond[Req, Res any](f *fakeAPI, name string, req *connect.Request[Req], fn func(*Req) (*Res, error)) (*connect.Response[Res], error) {
64+ f.record(name, req.Header(), req.Msg)
65+ if fn == nil {
66+ return nil, connect.NewError(connect.CodeUnimplemented, nil)
67+ }
68+ res, err := fn(req.Msg)
69+ if err != nil {
70+ return nil, err
71+ }
72+ return connect.NewResponse(res), nil
73+}
74+
75+func (f *fakeAPI) ListRepos(_ context.Context, r *connect.Request[ermite.ListReposRequest]) (*connect.Response[ermite.ListReposResponse], error) {
76+ return respond(f, "ListRepos", r, f.listRepos)
77+}
78+func (f *fakeAPI) GetRepo(_ context.Context, r *connect.Request[ermite.GetRepoRequest]) (*connect.Response[ermite.GetRepoResponse], error) {
79+ return respond(f, "GetRepo", r, f.getRepo)
80+}
81+func (f *fakeAPI) CreateRepo(_ context.Context, r *connect.Request[ermite.CreateRepoRequest]) (*connect.Response[ermite.CreateRepoResponse], error) {
82+ return respond(f, "CreateRepo", r, f.createRepo)
83+}
84+func (f *fakeAPI) UpdateRepo(_ context.Context, r *connect.Request[ermite.UpdateRepoRequest]) (*connect.Response[ermite.UpdateRepoResponse], error) {
85+ return respond(f, "UpdateRepo", r, f.updateRepo)
86+}
87+func (f *fakeAPI) DeleteRepo(_ context.Context, r *connect.Request[ermite.DeleteRepoRequest]) (*connect.Response[ermite.DeleteRepoResponse], error) {
88+ return respond(f, "DeleteRepo", r, f.deleteRepo)
89+}
90+func (f *fakeAPI) CreateToken(_ context.Context, r *connect.Request[ermite.CreateTokenRequest]) (*connect.Response[ermite.CreateTokenResponse], error) {
91+ return respond(f, "CreateToken", r, f.createToken)
92+}
93+func (f *fakeAPI) ListTokens(_ context.Context, r *connect.Request[ermite.ListTokensRequest]) (*connect.Response[ermite.ListTokensResponse], error) {
94+ return respond(f, "ListTokens", r, f.listTokens)
95+}
96+func (f *fakeAPI) RevokeToken(_ context.Context, r *connect.Request[ermite.RevokeTokenRequest]) (*connect.Response[ermite.RevokeTokenResponse], error) {
97+ return respond(f, "RevokeToken", r, f.revokeToken)
98+}
99+func (f *fakeAPI) GetVersion(_ context.Context, r *connect.Request[ermite.GetVersionRequest]) (*connect.Response[ermite.GetVersionResponse], error) {
100+ return respond(f, "GetVersion", r, f.getVersion)
101+}
102+func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error {
103+ f.record("Watch", r.Header(), r.Msg)
104+ if f.watch == nil {
105+ return connect.NewError(connect.CodeUnimplemented, nil)
106+ }
107+ return f.watch(ctx, r.Msg, st)
108+}
109+
110+// serve starts the fake under /-/api and returns its base URL.
111+func (f *fakeAPI) serve(t *testing.T) string {
112+ t.Helper()
113+ mux := http.NewServeMux()
114+ mount := func(path string, h http.Handler) { mux.Handle("/-/api"+path, http.StripPrefix("/-/api", h)) }
115+ p, h := ermiteconnect.NewRepoServiceHandler(f)
116+ mount(p, h)
117+ p, h = ermiteconnect.NewTokenServiceHandler(f)
118+ mount(p, h)
119+ p, h = ermiteconnect.NewServerServiceHandler(f)
120+ mount(p, h)
121+ p, h = ermiteconnect.NewEventServiceHandler(f)
122+ mount(p, h)
123+ srv := httptest.NewServer(mux)
124+ t.Cleanup(srv.Close)
125+ return srv.URL
126+}
127+
128+var testNow = time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)
129+
130+// result is the outcome of one CLI run.
131+type result struct {
132+ stdout, stderr string
133+ err error
134+}
135+
136+// runCLI runs the command tree in process with the given environment.
137+func runCLI(t *testing.T, vars map[string]string, stdin string, args ...string) result {
138+ t.Helper()
139+ env := &cliEnv{
140+ getenv: func(k string) string { return vars[k] },
141+ now: func() time.Time { return testNow },
142+ version: "v0.0.1",
143+ sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() },
144+ }
145+ root := newRootCmdWith(env)
146+ var out, errb bytes.Buffer
147+ root.SetOut(&out)
148+ root.SetErr(&errb)
149+ root.SetIn(strings.NewReader(stdin))
150+ root.SetArgs(args)
151+ ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
152+ defer cancel()
153+ err := root.ExecuteContext(ctx)
154+ return result{stdout: out.String(), stderr: errb.String(), err: err}
155+}
156+
157+func (r result) ok(t *testing.T) result {
158+ t.Helper()
159+ require.NoError(t, r.err, "stderr: %s", r.stderr)
160+ return r
161+}
162+
163+func ts(s string) *timestamppb.Timestamp {
164+ v, err := time.Parse(time.RFC3339, s)
165+ if err != nil {
166+ panic(err)
167+ }
168+ return timestamppb.New(v)
169+}
A cmd/ermite/cli_repo.go +396 -0
@@ -0,0 +1,396 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "bufio"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "strings"
12+ "time"
13+
14+ "connectrpc.com/connect"
15+ "github.com/spf13/cobra"
16+ "google.golang.org/protobuf/types/known/fieldmaskpb"
17+
18+ "bnema.dev/ermite-api/gen/ermite"
19+ "bnema.dev/ermite/internal/adapters/client"
20+)
21+
22+const (
23+ visibilityPublic = "public"
24+ visibilityPrivate = "private"
25+)
26+
27+// jsonRepo is the stable JSON shape of a repository.
28+type jsonRepo struct {
29+ Name string `json:"name"`
30+ Description string `json:"description"`
31+ Visibility string `json:"visibility"`
32+ DefaultBranch string `json:"default_branch"`
33+ CreatedAt *string `json:"created_at"`
34+ PushedAt *string `json:"pushed_at"`
35+}
36+
37+func toJSONRepo(r *ermite.Repo) jsonRepo {
38+ return jsonRepo{
39+ Name: r.GetName(),
40+ Description: r.GetDescription(),
41+ Visibility: visibilityName(r.GetVisibility()),
42+ DefaultBranch: r.GetDefaultBranch(),
43+ CreatedAt: jsonTime(r.GetCreatedAt()),
44+ PushedAt: jsonTime(r.GetPushedAt()),
45+ }
46+}
47+
48+func visibilityName(v ermite.Visibility) string {
49+ switch v {
50+ case ermite.Visibility_VISIBILITY_PUBLIC:
51+ return visibilityPublic
52+ case ermite.Visibility_VISIBILITY_PRIVATE:
53+ return visibilityPrivate
54+ default:
55+ return "unknown"
56+ }
57+}
58+
59+func parseVisibility(s string) (ermite.Visibility, error) {
60+ switch strings.ToLower(strings.TrimSpace(s)) {
61+ case visibilityPublic:
62+ return ermite.Visibility_VISIBILITY_PUBLIC, nil
63+ case visibilityPrivate:
64+ return ermite.Visibility_VISIBILITY_PRIVATE, nil
65+ default:
66+ return 0, fmt.Errorf("invalid visibility %q: use %s or %s", s, visibilityPublic, visibilityPrivate)
67+ }
68+}
69+
70+const jsonRepoHelp = `
71+JSON fields of a repository (--json): name, description, visibility ("public" or
72+"private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
73+null until the first push).`
74+
75+func newRepoCmd(env *cliEnv) *cobra.Command {
76+ repo := &cobra.Command{
77+ Use: "repo",
78+ Short: "Manage repositories on a remote server",
79+ Long: "Manage repositories on the server at ERMITE_URL, authenticated with ERMITE_TOKEN.",
80+ }
81+ repo.AddCommand(
82+ newRepoListCmd(env), newRepoShowCmd(env), newRepoCreateCmd(env),
83+ newRepoEditCmd(env), newRepoDeleteCmd(env),
84+ )
85+ return repo
86+}
87+
88+// listRepos reads every page of repositories.
89+func listRepos(ctx context.Context, c *client.Client) ([]*ermite.Repo, error) {
90+ var repos []*ermite.Repo
91+ token := ""
92+ for {
93+ res, err := c.Repos().ListRepos(ctx, connect.NewRequest(&ermite.ListReposRequest{PageToken: token}))
94+ if err != nil {
95+ return nil, c.Describe(err)
96+ }
97+ repos = append(repos, res.Msg.GetRepos()...)
98+ token = res.Msg.GetNextPageToken()
99+ if token == "" {
100+ return repos, nil
101+ }
102+ }
103+}
104+
105+// completeRepoNames completes the first argument with repository names. Any
106+// failure, including a missing configuration, silently completes nothing.
107+func completeRepoNames(env *cliEnv) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
108+ return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
109+ if len(args) > 0 {
110+ return nil, cobra.ShellCompDirectiveNoFileComp
111+ }
112+ names, err := repoNames(env.ctx(cmd), env, cmd, toComplete)
113+ if err != nil {
114+ return nil, cobra.ShellCompDirectiveNoFileComp
115+ }
116+ return names, cobra.ShellCompDirectiveNoFileComp
117+ }
118+}
119+
120+func repoNames(ctx context.Context, env *cliEnv, cmd *cobra.Command, prefix string) ([]string, error) {
121+ c, err := env.client(cmd, false)
122+ if err != nil {
123+ return nil, err
124+ }
125+ ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
126+ defer cancel()
127+ repos, err := listRepos(ctx, c)
128+ if err != nil {
129+ return nil, err
130+ }
131+ var names []string
132+ for _, r := range repos {
133+ if strings.HasPrefix(r.GetName(), prefix) {
134+ desc := visibilityName(r.GetVisibility())
135+ if d := clean(r.GetDescription()); d != "" {
136+ desc += ", " + d
137+ }
138+ names = append(names, r.GetName()+"\t"+desc)
139+ }
140+ }
141+ return names, nil
142+}
143+
144+func newRepoListCmd(env *cliEnv) *cobra.Command {
145+ cmd := &cobra.Command{
146+ Use: "list",
147+ Short: "List the repositories you can see",
148+ Long: "List the repositories you can see: all public ones, and the private ones your token may read." + jsonRepoHelp + "\n--json prints an array of repositories ordered by name.",
149+ Args: cobra.NoArgs,
150+ }
151+ asJSON := addJSONFlag(cmd)
152+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
153+ c, err := env.client(cmd, true)
154+ if err != nil {
155+ return err
156+ }
157+ repos, err := listRepos(env.ctx(cmd), c)
158+ if err != nil {
159+ return err
160+ }
161+ if *asJSON {
162+ out := make([]jsonRepo, 0, len(repos))
163+ for _, r := range repos {
164+ out = append(out, toJSONRepo(r))
165+ }
166+ return writeJSON(cmd.OutOrStdout(), out)
167+ }
168+ if len(repos) == 0 {
169+ noticef(cmd, "no repositories")
170+ return nil
171+ }
172+ rows := make([][]string, 0, len(repos))
173+ for _, r := range repos {
174+ rows = append(rows, []string{
175+ r.GetName(), visibilityName(r.GetVisibility()), r.GetDefaultBranch(),
176+ shortTime(r.GetPushedAt(), "never"), r.GetDescription(),
177+ })
178+ }
179+ return table(cmd.OutOrStdout(), []string{"NAME", "VISIBILITY", "BRANCH", "LAST PUSH", "DESCRIPTION"}, rows)
180+ }
181+ return cmd
182+}
183+
184+func printRepo(w io.Writer, r *ermite.Repo) error {
185+ desc := r.GetDescription()
186+ if desc == "" {
187+ desc = "-"
188+ }
189+ return fields(w, [][2]string{
190+ {"name", r.GetName()},
191+ {"visibility", visibilityName(r.GetVisibility())},
192+ {"default branch", r.GetDefaultBranch()},
193+ {"description", desc},
194+ {"created", shortTime(r.GetCreatedAt(), "-")},
195+ {"last push", shortTime(r.GetPushedAt(), "never")},
196+ })
197+}
198+
199+func newRepoShowCmd(env *cliEnv) *cobra.Command {
200+ cmd := &cobra.Command{
201+ Use: "show <name>",
202+ Short: "Show one repository",
203+ Long: "Show one repository." + jsonRepoHelp,
204+ Args: cobra.ExactArgs(1),
205+ ValidArgsFunction: completeRepoNames(env),
206+ }
207+ asJSON := addJSONFlag(cmd)
208+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
209+ c, err := env.client(cmd, true)
210+ if err != nil {
211+ return err
212+ }
213+ res, err := c.Repos().GetRepo(env.ctx(cmd), connect.NewRequest(&ermite.GetRepoRequest{Repo: args[0]}))
214+ if err != nil {
215+ return c.Describe(err)
216+ }
217+ if *asJSON {
218+ return writeJSON(cmd.OutOrStdout(), toJSONRepo(res.Msg.GetRepo()))
219+ }
220+ return printRepo(cmd.OutOrStdout(), res.Msg.GetRepo())
221+ }
222+ return cmd
223+}
224+
225+func newRepoCreateCmd(env *cliEnv) *cobra.Command {
226+ var (
227+ description string
228+ visibility string
229+ private bool
230+ )
231+ cmd := &cobra.Command{
232+ Use: "create <name>",
233+ Short: "Create an empty repository",
234+ Long: "Create an empty repository. It is private unless --visibility public is given. Names are\n" +
235+ "lowercase, 1-100 characters from [a-z0-9._-]. Needs the repo:write scope." + jsonRepoHelp,
236+ Args: cobra.ExactArgs(1),
237+ }
238+ cmd.Flags().StringVar(&description, "description", "", "one-line description")
239+ cmd.Flags().StringVar(&visibility, "visibility", visibilityPrivate, "visibility: public or private")
240+ cmd.Flags().BoolVar(&private, "private", false, "create a private repository (the default; same as --visibility private)")
241+ _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
242+ asJSON := addJSONFlag(cmd)
243+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
244+ req, err := buildCreateRepo(cmd, args[0], description, visibility, private)
245+ if err != nil {
246+ return err
247+ }
248+ c, err := env.client(cmd, true)
249+ if err != nil {
250+ return err
251+ }
252+ res, err := c.Repos().CreateRepo(env.ctx(cmd), connect.NewRequest(req))
253+ if err != nil {
254+ return c.Describe(err)
255+ }
256+ if *asJSON {
257+ return writeJSON(cmd.OutOrStdout(), toJSONRepo(res.Msg.GetRepo()))
258+ }
259+ return printRepo(cmd.OutOrStdout(), res.Msg.GetRepo())
260+ }
261+ return cmd
262+}
263+
264+func visibilityCompletion(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
265+ return []string{visibilityPublic, visibilityPrivate}, cobra.ShellCompDirectiveNoFileComp
266+}
267+
268+// buildCreateRepo maps the flags of `repo create` to the request.
269+func buildCreateRepo(cmd *cobra.Command, name, description, visibility string, private bool) (*ermite.CreateRepoRequest, error) {
270+ vis, err := parseVisibility(visibility)
271+ if err != nil {
272+ return nil, err
273+ }
274+ if private {
275+ if cmd.Flags().Changed("visibility") && vis != ermite.Visibility_VISIBILITY_PRIVATE {
276+ return nil, errors.New("--private conflicts with --visibility " + visibilityPublic)
277+ }
278+ vis = ermite.Visibility_VISIBILITY_PRIVATE
279+ }
280+ return &ermite.CreateRepoRequest{Name: name, Description: description, Visibility: vis}, nil
281+}
282+
283+func newRepoEditCmd(env *cliEnv) *cobra.Command {
284+ var description, visibility, defaultBranch string
285+ cmd := &cobra.Command{
286+ Use: "edit <name>",
287+ Short: "Change a repository's description, visibility or default branch",
288+ Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +
289+ "description. The default branch must already exist. Needs the repo:admin scope." + jsonRepoHelp,
290+ Args: cobra.ExactArgs(1),
291+ ValidArgsFunction: completeRepoNames(env),
292+ }
293+ cmd.Flags().StringVar(&description, "description", "", "new description (empty clears it)")
294+ cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
295+ cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
296+ _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
297+ asJSON := addJSONFlag(cmd)
298+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
299+ req, err := buildUpdateRepo(cmd, args[0], description, visibility, defaultBranch)
300+ if err != nil {
301+ return err
302+ }
303+ c, err := env.client(cmd, true)
304+ if err != nil {
305+ return err
306+ }
307+ res, err := c.Repos().UpdateRepo(env.ctx(cmd), connect.NewRequest(req))
308+ if err != nil {
309+ return c.Describe(err)
310+ }
311+ if *asJSON {
312+ return writeJSON(cmd.OutOrStdout(), toJSONRepo(res.Msg.GetRepo()))
313+ }
314+ return printRepo(cmd.OutOrStdout(), res.Msg.GetRepo())
315+ }
316+ return cmd
317+}
318+
319+// buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
320+// the user passed go into the update mask.
321+func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultBranch string) (*ermite.UpdateRepoRequest, error) {
322+ req := &ermite.UpdateRepoRequest{Repo: name}
323+ var paths []string
324+ if cmd.Flags().Changed("description") {
325+ req.Description = description
326+ paths = append(paths, "description")
327+ }
328+ if cmd.Flags().Changed("visibility") {
329+ vis, err := parseVisibility(visibility)
330+ if err != nil {
331+ return nil, err
332+ }
333+ req.Visibility = vis
334+ paths = append(paths, "visibility")
335+ }
336+ if cmd.Flags().Changed("default-branch") {
337+ branch := strings.TrimPrefix(strings.TrimSpace(defaultBranch), "refs/heads/")
338+ if branch == "" {
339+ return nil, errors.New("--default-branch must not be empty")
340+ }
341+ req.DefaultBranch = branch
342+ paths = append(paths, "default_branch")
343+ }
344+ if len(paths) == 0 {
345+ return nil, errors.New("nothing to change: give at least one of --description, --visibility, --default-branch")
346+ }
347+ req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
348+ return req, nil
349+}
350+
351+func newRepoDeleteCmd(env *cliEnv) *cobra.Command {
352+ var yes bool
353+ cmd := &cobra.Command{
354+ Use: "delete <name>",
355+ Short: "Delete a repository",
356+ Long: "Delete a repository. Without --yes you must type the repository name on standard input to\n" +
357+ "confirm. The server moves the data to its trash directory instead of destroying it; there is no\n" +
358+ "undelete command. Needs the repo:admin scope.",
359+ Args: cobra.ExactArgs(1),
360+ ValidArgsFunction: completeRepoNames(env),
361+ }
362+ cmd.Flags().BoolVarP(&yes, "yes", "y", false, "do not ask for confirmation")
363+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
364+ name := args[0]
365+ // Check the configuration before asking anything.
366+ c, err := env.client(cmd, true)
367+ if err != nil {
368+ return err
369+ }
370+ if !yes {
371+ if err := confirmName(cmd.InOrStdin(), cmd.ErrOrStderr(), name); err != nil {
372+ return err
373+ }
374+ }
375+ if _, err := c.Repos().DeleteRepo(env.ctx(cmd), connect.NewRequest(&ermite.DeleteRepoRequest{Repo: name})); err != nil {
376+ return c.Describe(err)
377+ }
378+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted %s\n", name)
379+ return err
380+ }
381+ return cmd
382+}
383+
384+// confirmName asks the user to type name and fails when the answer differs.
385+func confirmName(in io.Reader, prompt io.Writer, name string) error {
386+ _, _ = fmt.Fprintf(prompt, "This deletes repository %q from the server.\nType its name to confirm: ", name)
387+ line, err := bufio.NewReader(in).ReadString('\n')
388+ if err != nil && line == "" {
389+ _, _ = fmt.Fprintln(prompt)
390+ return errors.New("aborted: no confirmation read (pass --yes to skip the question)")
391+ }
392+ if strings.TrimSpace(line) != name {
393+ return errors.New("aborted: the name did not match")
394+ }
395+ return nil
396+}
@@ -0,0 +1,396 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "bufio"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "strings"
12+ "time"
13+
14+ "connectrpc.com/connect"
15+ "github.com/spf13/cobra"
16+ "google.golang.org/protobuf/types/known/fieldmaskpb"
17+
18+ "bnema.dev/ermite-api/gen/ermite"
19+ "bnema.dev/ermite/internal/adapters/client"
20+)
21+
22+const (
23+ visibilityPublic = "public"
24+ visibilityPrivate = "private"
25+)
26+
27+// jsonRepo is the stable JSON shape of a repository.
28+type jsonRepo struct {
29+ Name string `json:"name"`
30+ Description string `json:"description"`
31+ Visibility string `json:"visibility"`
32+ DefaultBranch string `json:"default_branch"`
33+ CreatedAt *string `json:"created_at"`
34+ PushedAt *string `json:"pushed_at"`
35+}
36+
37+func toJSONRepo(r *ermite.Repo) jsonRepo {
38+ return jsonRepo{
39+ Name: r.GetName(),
40+ Description: r.GetDescription(),
41+ Visibility: visibilityName(r.GetVisibility()),
42+ DefaultBranch: r.GetDefaultBranch(),
43+ CreatedAt: jsonTime(r.GetCreatedAt()),
44+ PushedAt: jsonTime(r.GetPushedAt()),
45+ }
46+}
47+
48+func visibilityName(v ermite.Visibility) string {
49+ switch v {
50+ case ermite.Visibility_VISIBILITY_PUBLIC:
51+ return visibilityPublic
52+ case ermite.Visibility_VISIBILITY_PRIVATE:
53+ return visibilityPrivate
54+ default:
55+ return "unknown"
56+ }
57+}
58+
59+func parseVisibility(s string) (ermite.Visibility, error) {
60+ switch strings.ToLower(strings.TrimSpace(s)) {
61+ case visibilityPublic:
62+ return ermite.Visibility_VISIBILITY_PUBLIC, nil
63+ case visibilityPrivate:
64+ return ermite.Visibility_VISIBILITY_PRIVATE, nil
65+ default:
66+ return 0, fmt.Errorf("invalid visibility %q: use %s or %s", s, visibilityPublic, visibilityPrivate)
67+ }
68+}
69+
70+const jsonRepoHelp = `
71+JSON fields of a repository (--json): name, description, visibility ("public" or
72+"private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
73+null until the first push).`
74+
75+func newRepoCmd(env *cliEnv) *cobra.Command {
76+ repo := &cobra.Command{
77+ Use: "repo",
78+ Short: "Manage repositories on a remote server",
79+ Long: "Manage repositories on the server at ERMITE_URL, authenticated with ERMITE_TOKEN.",
80+ }
81+ repo.AddCommand(
82+ newRepoListCmd(env), newRepoShowCmd(env), newRepoCreateCmd(env),
83+ newRepoEditCmd(env), newRepoDeleteCmd(env),
84+ )
85+ return repo
86+}
87+
88+// listRepos reads every page of repositories.
89+func listRepos(ctx context.Context, c *client.Client) ([]*ermite.Repo, error) {
90+ var repos []*ermite.Repo
91+ token := ""
92+ for {
93+ res, err := c.Repos().ListRepos(ctx, connect.NewRequest(&ermite.ListReposRequest{PageToken: token}))
94+ if err != nil {
95+ return nil, c.Describe(err)
96+ }
97+ repos = append(repos, res.Msg.GetRepos()...)
98+ token = res.Msg.GetNextPageToken()
99+ if token == "" {
100+ return repos, nil
101+ }
102+ }
103+}
104+
105+// completeRepoNames completes the first argument with repository names. Any
106+// failure, including a missing configuration, silently completes nothing.
107+func completeRepoNames(env *cliEnv) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
108+ return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
109+ if len(args) > 0 {
110+ return nil, cobra.ShellCompDirectiveNoFileComp
111+ }
112+ names, err := repoNames(env.ctx(cmd), env, cmd, toComplete)
113+ if err != nil {
114+ return nil, cobra.ShellCompDirectiveNoFileComp
115+ }
116+ return names, cobra.ShellCompDirectiveNoFileComp
117+ }
118+}
119+
120+func repoNames(ctx context.Context, env *cliEnv, cmd *cobra.Command, prefix string) ([]string, error) {
121+ c, err := env.client(cmd, false)
122+ if err != nil {
123+ return nil, err
124+ }
125+ ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
126+ defer cancel()
127+ repos, err := listRepos(ctx, c)
128+ if err != nil {
129+ return nil, err
130+ }
131+ var names []string
132+ for _, r := range repos {
133+ if strings.HasPrefix(r.GetName(), prefix) {
134+ desc := visibilityName(r.GetVisibility())
135+ if d := clean(r.GetDescription()); d != "" {
136+ desc += ", " + d
137+ }
138+ names = append(names, r.GetName()+"\t"+desc)
139+ }
140+ }
141+ return names, nil
142+}
143+
144+func newRepoListCmd(env *cliEnv) *cobra.Command {
145+ cmd := &cobra.Command{
146+ Use: "list",
147+ Short: "List the repositories you can see",
148+ Long: "List the repositories you can see: all public ones, and the private ones your token may read." + jsonRepoHelp + "\n--json prints an array of repositories ordered by name.",
149+ Args: cobra.NoArgs,
150+ }
151+ asJSON := addJSONFlag(cmd)
152+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
153+ c, err := env.client(cmd, true)
154+ if err != nil {
155+ return err
156+ }
157+ repos, err := listRepos(env.ctx(cmd), c)
158+ if err != nil {
159+ return err
160+ }
161+ if *asJSON {
162+ out := make([]jsonRepo, 0, len(repos))
163+ for _, r := range repos {
164+ out = append(out, toJSONRepo(r))
165+ }
166+ return writeJSON(cmd.OutOrStdout(), out)
167+ }
168+ if len(repos) == 0 {
169+ noticef(cmd, "no repositories")
170+ return nil
171+ }
172+ rows := make([][]string, 0, len(repos))
173+ for _, r := range repos {
174+ rows = append(rows, []string{
175+ r.GetName(), visibilityName(r.GetVisibility()), r.GetDefaultBranch(),
176+ shortTime(r.GetPushedAt(), "never"), r.GetDescription(),
177+ })
178+ }
179+ return table(cmd.OutOrStdout(), []string{"NAME", "VISIBILITY", "BRANCH", "LAST PUSH", "DESCRIPTION"}, rows)
180+ }
181+ return cmd
182+}
183+
184+func printRepo(w io.Writer, r *ermite.Repo) error {
185+ desc := r.GetDescription()
186+ if desc == "" {
187+ desc = "-"
188+ }
189+ return fields(w, [][2]string{
190+ {"name", r.GetName()},
191+ {"visibility", visibilityName(r.GetVisibility())},
192+ {"default branch", r.GetDefaultBranch()},
193+ {"description", desc},
194+ {"created", shortTime(r.GetCreatedAt(), "-")},
195+ {"last push", shortTime(r.GetPushedAt(), "never")},
196+ })
197+}
198+
199+func newRepoShowCmd(env *cliEnv) *cobra.Command {
200+ cmd := &cobra.Command{
201+ Use: "show <name>",
202+ Short: "Show one repository",
203+ Long: "Show one repository." + jsonRepoHelp,
204+ Args: cobra.ExactArgs(1),
205+ ValidArgsFunction: completeRepoNames(env),
206+ }
207+ asJSON := addJSONFlag(cmd)
208+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
209+ c, err := env.client(cmd, true)
210+ if err != nil {
211+ return err
212+ }
213+ res, err := c.Repos().GetRepo(env.ctx(cmd), connect.NewRequest(&ermite.GetRepoRequest{Repo: args[0]}))
214+ if err != nil {
215+ return c.Describe(err)
216+ }
217+ if *asJSON {
218+ return writeJSON(cmd.OutOrStdout(), toJSONRepo(res.Msg.GetRepo()))
219+ }
220+ return printRepo(cmd.OutOrStdout(), res.Msg.GetRepo())
221+ }
222+ return cmd
223+}
224+
225+func newRepoCreateCmd(env *cliEnv) *cobra.Command {
226+ var (
227+ description string
228+ visibility string
229+ private bool
230+ )
231+ cmd := &cobra.Command{
232+ Use: "create <name>",
233+ Short: "Create an empty repository",
234+ Long: "Create an empty repository. It is private unless --visibility public is given. Names are\n" +
235+ "lowercase, 1-100 characters from [a-z0-9._-]. Needs the repo:write scope." + jsonRepoHelp,
236+ Args: cobra.ExactArgs(1),
237+ }
238+ cmd.Flags().StringVar(&description, "description", "", "one-line description")
239+ cmd.Flags().StringVar(&visibility, "visibility", visibilityPrivate, "visibility: public or private")
240+ cmd.Flags().BoolVar(&private, "private", false, "create a private repository (the default; same as --visibility private)")
241+ _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
242+ asJSON := addJSONFlag(cmd)
243+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
244+ req, err := buildCreateRepo(cmd, args[0], description, visibility, private)
245+ if err != nil {
246+ return err
247+ }
248+ c, err := env.client(cmd, true)
249+ if err != nil {
250+ return err
251+ }
252+ res, err := c.Repos().CreateRepo(env.ctx(cmd), connect.NewRequest(req))
253+ if err != nil {
254+ return c.Describe(err)
255+ }
256+ if *asJSON {
257+ return writeJSON(cmd.OutOrStdout(), toJSONRepo(res.Msg.GetRepo()))
258+ }
259+ return printRepo(cmd.OutOrStdout(), res.Msg.GetRepo())
260+ }
261+ return cmd
262+}
263+
264+func visibilityCompletion(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
265+ return []string{visibilityPublic, visibilityPrivate}, cobra.ShellCompDirectiveNoFileComp
266+}
267+
268+// buildCreateRepo maps the flags of `repo create` to the request.
269+func buildCreateRepo(cmd *cobra.Command, name, description, visibility string, private bool) (*ermite.CreateRepoRequest, error) {
270+ vis, err := parseVisibility(visibility)
271+ if err != nil {
272+ return nil, err
273+ }
274+ if private {
275+ if cmd.Flags().Changed("visibility") && vis != ermite.Visibility_VISIBILITY_PRIVATE {
276+ return nil, errors.New("--private conflicts with --visibility " + visibilityPublic)
277+ }
278+ vis = ermite.Visibility_VISIBILITY_PRIVATE
279+ }
280+ return &ermite.CreateRepoRequest{Name: name, Description: description, Visibility: vis}, nil
281+}
282+
283+func newRepoEditCmd(env *cliEnv) *cobra.Command {
284+ var description, visibility, defaultBranch string
285+ cmd := &cobra.Command{
286+ Use: "edit <name>",
287+ Short: "Change a repository's description, visibility or default branch",
288+ Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +
289+ "description. The default branch must already exist. Needs the repo:admin scope." + jsonRepoHelp,
290+ Args: cobra.ExactArgs(1),
291+ ValidArgsFunction: completeRepoNames(env),
292+ }
293+ cmd.Flags().StringVar(&description, "description", "", "new description (empty clears it)")
294+ cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
295+ cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
296+ _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
297+ asJSON := addJSONFlag(cmd)
298+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
299+ req, err := buildUpdateRepo(cmd, args[0], description, visibility, defaultBranch)
300+ if err != nil {
301+ return err
302+ }
303+ c, err := env.client(cmd, true)
304+ if err != nil {
305+ return err
306+ }
307+ res, err := c.Repos().UpdateRepo(env.ctx(cmd), connect.NewRequest(req))
308+ if err != nil {
309+ return c.Describe(err)
310+ }
311+ if *asJSON {
312+ return writeJSON(cmd.OutOrStdout(), toJSONRepo(res.Msg.GetRepo()))
313+ }
314+ return printRepo(cmd.OutOrStdout(), res.Msg.GetRepo())
315+ }
316+ return cmd
317+}
318+
319+// buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
320+// the user passed go into the update mask.
321+func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultBranch string) (*ermite.UpdateRepoRequest, error) {
322+ req := &ermite.UpdateRepoRequest{Repo: name}
323+ var paths []string
324+ if cmd.Flags().Changed("description") {
325+ req.Description = description
326+ paths = append(paths, "description")
327+ }
328+ if cmd.Flags().Changed("visibility") {
329+ vis, err := parseVisibility(visibility)
330+ if err != nil {
331+ return nil, err
332+ }
333+ req.Visibility = vis
334+ paths = append(paths, "visibility")
335+ }
336+ if cmd.Flags().Changed("default-branch") {
337+ branch := strings.TrimPrefix(strings.TrimSpace(defaultBranch), "refs/heads/")
338+ if branch == "" {
339+ return nil, errors.New("--default-branch must not be empty")
340+ }
341+ req.DefaultBranch = branch
342+ paths = append(paths, "default_branch")
343+ }
344+ if len(paths) == 0 {
345+ return nil, errors.New("nothing to change: give at least one of --description, --visibility, --default-branch")
346+ }
347+ req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
348+ return req, nil
349+}
350+
351+func newRepoDeleteCmd(env *cliEnv) *cobra.Command {
352+ var yes bool
353+ cmd := &cobra.Command{
354+ Use: "delete <name>",
355+ Short: "Delete a repository",
356+ Long: "Delete a repository. Without --yes you must type the repository name on standard input to\n" +
357+ "confirm. The server moves the data to its trash directory instead of destroying it; there is no\n" +
358+ "undelete command. Needs the repo:admin scope.",
359+ Args: cobra.ExactArgs(1),
360+ ValidArgsFunction: completeRepoNames(env),
361+ }
362+ cmd.Flags().BoolVarP(&yes, "yes", "y", false, "do not ask for confirmation")
363+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
364+ name := args[0]
365+ // Check the configuration before asking anything.
366+ c, err := env.client(cmd, true)
367+ if err != nil {
368+ return err
369+ }
370+ if !yes {
371+ if err := confirmName(cmd.InOrStdin(), cmd.ErrOrStderr(), name); err != nil {
372+ return err
373+ }
374+ }
375+ if _, err := c.Repos().DeleteRepo(env.ctx(cmd), connect.NewRequest(&ermite.DeleteRepoRequest{Repo: name})); err != nil {
376+ return c.Describe(err)
377+ }
378+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted %s\n", name)
379+ return err
380+ }
381+ return cmd
382+}
383+
384+// confirmName asks the user to type name and fails when the answer differs.
385+func confirmName(in io.Reader, prompt io.Writer, name string) error {
386+ _, _ = fmt.Fprintf(prompt, "This deletes repository %q from the server.\nType its name to confirm: ", name)
387+ line, err := bufio.NewReader(in).ReadString('\n')
388+ if err != nil && line == "" {
389+ _, _ = fmt.Fprintln(prompt)
390+ return errors.New("aborted: no confirmation read (pass --yes to skip the question)")
391+ }
392+ if strings.TrimSpace(line) != name {
393+ return errors.New("aborted: the name did not match")
394+ }
395+ return nil
396+}
A cmd/ermite/cli_repo_test.go +397 -0
@@ -0,0 +1,397 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "errors"
7+ "testing"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+ "google.golang.org/protobuf/proto"
13+
14+ "bnema.dev/ermite-api/gen/ermite"
15+)
16+
17+var (
18+ repoAlpha = &ermite.Repo{
19+ Name: "alpha", Description: "The first one", Visibility: ermite.Visibility_VISIBILITY_PUBLIC,
20+ DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"),
21+ }
22+ repoBeta = &ermite.Repo{
23+ Name: "beta", Visibility: ermite.Visibility_VISIBILITY_PRIVATE,
24+ DefaultBranch: "trunk", CreatedAt: ts("2026-02-02T00:00:00Z"),
25+ }
26+)
27+
28+func envFor(url string) map[string]string {
29+ return map[string]string{"ERMITE_URL": url, "ERMITE_TOKEN": "ermite_tok"}
30+}
31+
32+func TestRepoListTable(t *testing.T) {
33+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
34+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil
35+ }}
36+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "list").ok(t)
37+ assert.Equal(t, ""+
38+ "NAME VISIBILITY BRANCH LAST PUSH DESCRIPTION\n"+
39+ "alpha public main 2026-03-04 05:06 The first one\n"+
40+ "beta private trunk never \n", res.stdout)
41+ assert.Empty(t, res.stderr)
42+ assert.Equal(t, "Bearer ermite_tok", f.header[0].Get("Authorization"))
43+ assert.Equal(t, "ermite/v0.0.1 (cli)", f.header[0].Get("User-Agent"))
44+}
45+
46+func TestRepoListJSON(t *testing.T) {
47+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
48+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil
49+ }}
50+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "list", "--json").ok(t)
51+ assert.JSONEq(t, `[
52+ {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z"},
53+ {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null}
54+ ]`, res.stdout)
55+}
56+
57+func TestRepoListEmpty(t *testing.T) {
58+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
59+ return &ermite.ListReposResponse{}, nil
60+ }}
61+ url := f.serve(t)
62+ res := runCLI(t, envFor(url), "", "repo", "list").ok(t)
63+ assert.Empty(t, res.stdout)
64+ assert.Equal(t, "ermite: no repositories\n", res.stderr)
65+ res = runCLI(t, envFor(url), "", "repo", "list", "--json").ok(t)
66+ assert.Equal(t, "[]\n", res.stdout, "an empty list is [] not null")
67+}
68+
69+func TestRepoListFollowsPages(t *testing.T) {
70+ f := &fakeAPI{listRepos: func(r *ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
71+ if r.GetPageToken() == "" {
72+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}, NextPageToken: "p2"}, nil
73+ }
74+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoBeta}}, nil
75+ }}
76+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "list", "--json").ok(t)
77+ assert.Contains(t, res.stdout, `"alpha"`)
78+ assert.Contains(t, res.stdout, `"beta"`)
79+ _, reqs := f.snapshot()
80+ require.Len(t, reqs, 2)
81+ assert.Equal(t, "p2", reqs[1].(*ermite.ListReposRequest).GetPageToken())
82+}
83+
84+func TestRepoShow(t *testing.T) {
85+ f := &fakeAPI{getRepo: func(r *ermite.GetRepoRequest) (*ermite.GetRepoResponse, error) {
86+ return &ermite.GetRepoResponse{Repo: repoAlpha}, nil
87+ }}
88+ url := f.serve(t)
89+ res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
90+ assert.Equal(t, ""+
91+ "name alpha\n"+
92+ "visibility public\n"+
93+ "default branch main\n"+
94+ "description The first one\n"+
95+ "created 2026-01-02 03:04\n"+
96+ "last push 2026-03-04 05:06\n", res.stdout)
97+ _, reqs := f.snapshot()
98+ assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
99+
100+ res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
101+ assert.JSONEq(t, `{"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z"}`, res.stdout)
102+}
103+
104+func TestRepoShowNeedsOneArg(t *testing.T) {
105+ res := runCLI(t, envFor("http://127.0.0.1:1"), "", "repo", "show")
106+ require.Error(t, res.err)
107+}
108+
109+func TestRepoCreateMapsFlags(t *testing.T) {
110+ tests := []struct {
111+ name string
112+ args []string
113+ want ermite.Visibility
114+ desc string
115+ }{
116+ {"default is private", []string{"x"}, ermite.Visibility_VISIBILITY_PRIVATE, ""},
117+ {"--private", []string{"x", "--private"}, ermite.Visibility_VISIBILITY_PRIVATE, ""},
118+ {"--visibility public", []string{"x", "--visibility", "public", "--description", "d d"}, ermite.Visibility_VISIBILITY_PUBLIC, "d d"},
119+ {"--visibility private --private", []string{"x", "--visibility", "private", "--private"}, ermite.Visibility_VISIBILITY_PRIVATE, ""},
120+ {"case insensitive", []string{"x", "--visibility", "Public"}, ermite.Visibility_VISIBILITY_PUBLIC, ""},
121+ }
122+ for _, tt := range tests {
123+ t.Run(tt.name, func(t *testing.T) {
124+ f := &fakeAPI{createRepo: func(r *ermite.CreateRepoRequest) (*ermite.CreateRepoResponse, error) {
125+ return &ermite.CreateRepoResponse{Repo: repoAlpha}, nil
126+ }}
127+ runCLI(t, envFor(f.serve(t)), "", append([]string{"repo", "create"}, tt.args...)...).ok(t)
128+ _, reqs := f.snapshot()
129+ require.Len(t, reqs, 1)
130+ got := reqs[0].(*ermite.CreateRepoRequest)
131+ assert.Equal(t, "x", got.GetName())
132+ assert.Equal(t, tt.want, got.GetVisibility())
133+ assert.Equal(t, tt.desc, got.GetDescription())
134+ })
135+ }
136+}
137+
138+func TestRepoCreateRejectsBadFlagsWithoutCalling(t *testing.T) {
139+ f := &fakeAPI{}
140+ url := f.serve(t)
141+ for _, args := range [][]string{
142+ {"repo", "create", "x", "--visibility", "secret"},
143+ {"repo", "create", "x", "--visibility", "public", "--private"},
144+ } {
145+ res := runCLI(t, envFor(url), "", args...)
146+ require.Error(t, res.err, "%v", args)
147+ }
148+ calls, _ := f.snapshot()
149+ assert.Empty(t, calls)
150+}
151+
152+func TestRepoCreateJSON(t *testing.T) {
153+ f := &fakeAPI{createRepo: func(r *ermite.CreateRepoRequest) (*ermite.CreateRepoResponse, error) {
154+ return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
155+ }}
156+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)
157+ assert.JSONEq(t, `{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null}`, res.stdout)
158+}
159+
160+func TestRepoEditMask(t *testing.T) {
161+ tests := []struct {
162+ name string
163+ args []string
164+ paths []string
165+ check func(*testing.T, *ermite.UpdateRepoRequest)
166+ }{
167+ {"description only", []string{"--description", "new"}, []string{"description"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
168+ assert.Equal(t, "new", r.GetDescription())
169+ }},
170+ {"clear description", []string{"--description", ""}, []string{"description"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
171+ assert.Empty(t, r.GetDescription())
172+ }},
173+ {"visibility", []string{"--visibility", "public"}, []string{"visibility"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
174+ assert.Equal(t, ermite.Visibility_VISIBILITY_PUBLIC, r.GetVisibility())
175+ }},
176+ {"default branch", []string{"--default-branch", "refs/heads/dev"}, []string{"default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
177+ assert.Equal(t, "dev", r.GetDefaultBranch())
178+ }},
179+ {"all", []string{"--default-branch", "dev", "--visibility", "private", "--description", "d"},
180+ []string{"description", "visibility", "default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {}},
181+ }
182+ for _, tt := range tests {
183+ t.Run(tt.name, func(t *testing.T) {
184+ f := &fakeAPI{updateRepo: func(r *ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) {
185+ return &ermite.UpdateRepoResponse{Repo: repoAlpha}, nil
186+ }}
187+ runCLI(t, envFor(f.serve(t)), "", append([]string{"repo", "edit", "alpha"}, tt.args...)...).ok(t)
188+ _, reqs := f.snapshot()
189+ require.Len(t, reqs, 1)
190+ got := reqs[0].(*ermite.UpdateRepoRequest)
191+ assert.Equal(t, "alpha", got.GetRepo())
192+ assert.ElementsMatch(t, tt.paths, got.GetUpdateMask().GetPaths())
193+ tt.check(t, got)
194+ })
195+ }
196+}
197+
198+func TestRepoEditRejects(t *testing.T) {
199+ f := &fakeAPI{}
200+ url := f.serve(t)
201+ for _, tt := range []struct {
202+ args []string
203+ want string
204+ }{
205+ {[]string{"repo", "edit", "alpha"}, "nothing to change"},
206+ {[]string{"repo", "edit", "alpha", "--visibility", "x"}, "invalid visibility"},
207+ {[]string{"repo", "edit", "alpha", "--default-branch", " "}, "must not be empty"},
208+ } {
209+ res := runCLI(t, envFor(url), "", tt.args...)
210+ require.Error(t, res.err)
211+ assert.Contains(t, res.err.Error(), tt.want)
212+ }
213+ calls, _ := f.snapshot()
214+ assert.Empty(t, calls)
215+}
216+
217+func TestRepoDeleteConfirmation(t *testing.T) {
218+ newFake := func() *fakeAPI {
219+ return &fakeAPI{deleteRepo: func(*ermite.DeleteRepoRequest) (*ermite.DeleteRepoResponse, error) {
220+ return &ermite.DeleteRepoResponse{}, nil
221+ }}
222+ }
223+ t.Run("typed name deletes", func(t *testing.T) {
224+ f := newFake()
225+ res := runCLI(t, envFor(f.serve(t)), "alpha\n", "repo", "delete", "alpha").ok(t)
226+ assert.Equal(t, "deleted alpha\n", res.stdout)
227+ assert.Contains(t, res.stderr, "Type its name to confirm")
228+ calls, reqs := f.snapshot()
229+ assert.Equal(t, []string{"DeleteRepo"}, calls)
230+ assert.Equal(t, "alpha", reqs[0].(*ermite.DeleteRepoRequest).GetRepo())
231+ })
232+ t.Run("last line without newline is accepted", func(t *testing.T) {
233+ f := newFake()
234+ runCLI(t, envFor(f.serve(t)), "alpha", "repo", "delete", "alpha").ok(t)
235+ calls, _ := f.snapshot()
236+ assert.Equal(t, []string{"DeleteRepo"}, calls)
237+ })
238+ t.Run("wrong name aborts", func(t *testing.T) {
239+ f := newFake()
240+ res := runCLI(t, envFor(f.serve(t)), "beta\n", "repo", "delete", "alpha")
241+ require.Error(t, res.err)
242+ assert.Contains(t, res.err.Error(), "did not match")
243+ calls, _ := f.snapshot()
244+ assert.Empty(t, calls)
245+ })
246+ t.Run("empty stdin aborts", func(t *testing.T) {
247+ f := newFake()
248+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "delete", "alpha")
249+ require.Error(t, res.err)
250+ assert.Contains(t, res.err.Error(), "--yes")
251+ calls, _ := f.snapshot()
252+ assert.Empty(t, calls)
253+ })
254+ t.Run("--yes skips the question", func(t *testing.T) {
255+ f := newFake()
256+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "delete", "alpha", "--yes").ok(t)
257+ assert.Equal(t, "deleted alpha\n", res.stdout)
258+ assert.NotContains(t, res.stderr, "confirm")
259+ })
260+ t.Run("-y", func(t *testing.T) {
261+ f := newFake()
262+ runCLI(t, envFor(f.serve(t)), "", "repo", "delete", "alpha", "-y").ok(t)
263+ })
264+}
265+
266+func TestRepoErrorsAreFriendly(t *testing.T) {
267+ f := &fakeAPI{getRepo: func(*ermite.GetRepoRequest) (*ermite.GetRepoResponse, error) {
268+ return nil, connect.NewError(connect.CodeNotFound, errors.New("repository \"nope\" not found"))
269+ }}
270+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "show", "nope")
271+ require.Error(t, res.err)
272+ assert.Equal(t, "not found: repository \"nope\" not found", res.err.Error())
273+}
274+
275+func TestUnauthenticatedHintsAtToken(t *testing.T) {
276+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
277+ return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid token"))
278+ }}
279+ res := runCLI(t, map[string]string{"ERMITE_URL": f.serve(t)}, "", "repo", "list")
280+ require.Error(t, res.err)
281+ assert.Contains(t, res.err.Error(), "ERMITE_TOKEN")
282+}
283+
284+func TestMissingURL(t *testing.T) {
285+ res := runCLI(t, nil, "", "repo", "list")
286+ require.Error(t, res.err)
287+ assert.Contains(t, res.err.Error(), "ERMITE_URL is not set")
288+}
289+
290+func TestInvalidURL(t *testing.T) {
291+ res := runCLI(t, map[string]string{"ERMITE_URL": "git.example.com"}, "", "repo", "list")
292+ require.Error(t, res.err)
293+ assert.Contains(t, res.err.Error(), "ERMITE_URL")
294+ assert.Contains(t, res.err.Error(), "http")
295+}
296+
297+func TestUnreachableServer(t *testing.T) {
298+ res := runCLI(t, envFor("http://127.0.0.1:1"), "", "repo", "list")
299+ require.Error(t, res.err)
300+ assert.Contains(t, res.err.Error(), "cannot reach the server at http://127.0.0.1:1")
301+ assert.NotContains(t, res.err.Error(), "\n")
302+}
303+
304+func TestInsecureTokenWarningOnStderr(t *testing.T) {
305+ // A non-loopback http URL: nothing listens there, the warning comes first.
306+ res := runCLI(t, map[string]string{"ERMITE_URL": "http://ermite.invalid:1", "ERMITE_TOKEN": "ermite_tok"}, "", "repo", "list")
307+ require.Error(t, res.err)
308+ assert.Contains(t, res.stderr, "ermite: warning: ERMITE_TOKEN is sent over plain http to ermite.invalid")
309+ assert.NotContains(t, res.stderr, "ermite_tok")
310+}
311+
312+func TestLoopbackHTTPDoesNotWarn(t *testing.T) {
313+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
314+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
315+ }}
316+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "list").ok(t)
317+ assert.Empty(t, res.stderr)
318+}
319+
320+func TestTerminalControlCharactersAreNeutralised(t *testing.T) {
321+ evil := &ermite.Repo{Name: "x", Description: "a\x1b[31mred\nline", Visibility: ermite.Visibility_VISIBILITY_PUBLIC}
322+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
323+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{evil}}, nil
324+ }}
325+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "list").ok(t)
326+ assert.NotContains(t, res.stdout, "\x1b")
327+ assert.Equal(t, 2, len(splitLines(res.stdout)), "header and one row")
328+}
329+
330+func splitLines(s string) []string {
331+ var out []string
332+ cur := ""
333+ for _, r := range s {
334+ if r == '\n' {
335+ out = append(out, cur)
336+ cur = ""
337+ continue
338+ }
339+ cur += string(r)
340+ }
341+ if cur != "" {
342+ out = append(out, cur)
343+ }
344+ return out
345+}
346+
347+func TestRepoNameCompletion(t *testing.T) {
348+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
349+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil
350+ }}
351+ vars := envFor(f.serve(t))
352+ res := runCLI(t, vars, "", "__complete", "repo", "show", "a").ok(t)
353+ assert.Contains(t, res.stdout, "alpha\tpublic, The first one")
354+ assert.NotContains(t, res.stdout, "beta")
355+
356+ res = runCLI(t, vars, "", "__complete", "repo", "delete", "").ok(t)
357+ assert.Contains(t, res.stdout, "alpha")
358+ assert.Contains(t, res.stdout, "beta")
359+
360+ // A second argument gets no repository names.
361+ res = runCLI(t, vars, "", "__complete", "repo", "show", "alpha", "").ok(t)
362+ assert.NotContains(t, res.stdout, "beta")
363+
364+ res = runCLI(t, vars, "", "__complete", "watch", "").ok(t)
365+ assert.Contains(t, res.stdout, "alpha")
366+}
367+
368+func TestCompletionIsSilentOnErrors(t *testing.T) {
369+ denied := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
370+ return nil, connect.NewError(connect.CodeUnauthenticated, nil)
371+ }}
372+ for name, vars := range map[string]map[string]string{
373+ "no config": nil,
374+ "bad url": {"ERMITE_URL": "nope"},
375+ "unreachable": envFor("http://ermite.invalid:1"),
376+ "rejected": envFor(denied.serve(t)),
377+ } {
378+ t.Run(name, func(t *testing.T) {
379+ res := runCLI(t, vars, "", "__complete", "repo", "show", "").ok(t)
380+ assert.NotContains(t, res.stdout, "alpha")
381+ assert.NotContains(t, res.stderr, "warning", "completion must not print warnings")
382+ assert.Contains(t, res.stdout, ":4", "cobra's no-file-completion directive")
383+ })
384+ }
385+}
386+
387+func TestShellCompletionScriptIsAvailable(t *testing.T) {
388+ res := runCLI(t, nil, "", "completion", "bash").ok(t)
389+ assert.Contains(t, res.stdout, "bash completion")
390+}
391+
392+func TestToJSONRepoFromProto(t *testing.T) {
393+ r := toJSONRepo(proto.Clone(repoBeta).(*ermite.Repo))
394+ assert.Nil(t, r.PushedAt)
395+ assert.Equal(t, "private", r.Visibility)
396+ assert.Equal(t, "unknown", toJSONRepo(&ermite.Repo{}).Visibility)
397+}
@@ -0,0 +1,397 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "errors"
7+ "testing"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+ "google.golang.org/protobuf/proto"
13+
14+ "bnema.dev/ermite-api/gen/ermite"
15+)
16+
17+var (
18+ repoAlpha = &ermite.Repo{
19+ Name: "alpha", Description: "The first one", Visibility: ermite.Visibility_VISIBILITY_PUBLIC,
20+ DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"),
21+ }
22+ repoBeta = &ermite.Repo{
23+ Name: "beta", Visibility: ermite.Visibility_VISIBILITY_PRIVATE,
24+ DefaultBranch: "trunk", CreatedAt: ts("2026-02-02T00:00:00Z"),
25+ }
26+)
27+
28+func envFor(url string) map[string]string {
29+ return map[string]string{"ERMITE_URL": url, "ERMITE_TOKEN": "ermite_tok"}
30+}
31+
32+func TestRepoListTable(t *testing.T) {
33+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
34+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil
35+ }}
36+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "list").ok(t)
37+ assert.Equal(t, ""+
38+ "NAME VISIBILITY BRANCH LAST PUSH DESCRIPTION\n"+
39+ "alpha public main 2026-03-04 05:06 The first one\n"+
40+ "beta private trunk never \n", res.stdout)
41+ assert.Empty(t, res.stderr)
42+ assert.Equal(t, "Bearer ermite_tok", f.header[0].Get("Authorization"))
43+ assert.Equal(t, "ermite/v0.0.1 (cli)", f.header[0].Get("User-Agent"))
44+}
45+
46+func TestRepoListJSON(t *testing.T) {
47+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
48+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil
49+ }}
50+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "list", "--json").ok(t)
51+ assert.JSONEq(t, `[
52+ {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z"},
53+ {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null}
54+ ]`, res.stdout)
55+}
56+
57+func TestRepoListEmpty(t *testing.T) {
58+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
59+ return &ermite.ListReposResponse{}, nil
60+ }}
61+ url := f.serve(t)
62+ res := runCLI(t, envFor(url), "", "repo", "list").ok(t)
63+ assert.Empty(t, res.stdout)
64+ assert.Equal(t, "ermite: no repositories\n", res.stderr)
65+ res = runCLI(t, envFor(url), "", "repo", "list", "--json").ok(t)
66+ assert.Equal(t, "[]\n", res.stdout, "an empty list is [] not null")
67+}
68+
69+func TestRepoListFollowsPages(t *testing.T) {
70+ f := &fakeAPI{listRepos: func(r *ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
71+ if r.GetPageToken() == "" {
72+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}, NextPageToken: "p2"}, nil
73+ }
74+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoBeta}}, nil
75+ }}
76+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "list", "--json").ok(t)
77+ assert.Contains(t, res.stdout, `"alpha"`)
78+ assert.Contains(t, res.stdout, `"beta"`)
79+ _, reqs := f.snapshot()
80+ require.Len(t, reqs, 2)
81+ assert.Equal(t, "p2", reqs[1].(*ermite.ListReposRequest).GetPageToken())
82+}
83+
84+func TestRepoShow(t *testing.T) {
85+ f := &fakeAPI{getRepo: func(r *ermite.GetRepoRequest) (*ermite.GetRepoResponse, error) {
86+ return &ermite.GetRepoResponse{Repo: repoAlpha}, nil
87+ }}
88+ url := f.serve(t)
89+ res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
90+ assert.Equal(t, ""+
91+ "name alpha\n"+
92+ "visibility public\n"+
93+ "default branch main\n"+
94+ "description The first one\n"+
95+ "created 2026-01-02 03:04\n"+
96+ "last push 2026-03-04 05:06\n", res.stdout)
97+ _, reqs := f.snapshot()
98+ assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
99+
100+ res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
101+ assert.JSONEq(t, `{"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z"}`, res.stdout)
102+}
103+
104+func TestRepoShowNeedsOneArg(t *testing.T) {
105+ res := runCLI(t, envFor("http://127.0.0.1:1"), "", "repo", "show")
106+ require.Error(t, res.err)
107+}
108+
109+func TestRepoCreateMapsFlags(t *testing.T) {
110+ tests := []struct {
111+ name string
112+ args []string
113+ want ermite.Visibility
114+ desc string
115+ }{
116+ {"default is private", []string{"x"}, ermite.Visibility_VISIBILITY_PRIVATE, ""},
117+ {"--private", []string{"x", "--private"}, ermite.Visibility_VISIBILITY_PRIVATE, ""},
118+ {"--visibility public", []string{"x", "--visibility", "public", "--description", "d d"}, ermite.Visibility_VISIBILITY_PUBLIC, "d d"},
119+ {"--visibility private --private", []string{"x", "--visibility", "private", "--private"}, ermite.Visibility_VISIBILITY_PRIVATE, ""},
120+ {"case insensitive", []string{"x", "--visibility", "Public"}, ermite.Visibility_VISIBILITY_PUBLIC, ""},
121+ }
122+ for _, tt := range tests {
123+ t.Run(tt.name, func(t *testing.T) {
124+ f := &fakeAPI{createRepo: func(r *ermite.CreateRepoRequest) (*ermite.CreateRepoResponse, error) {
125+ return &ermite.CreateRepoResponse{Repo: repoAlpha}, nil
126+ }}
127+ runCLI(t, envFor(f.serve(t)), "", append([]string{"repo", "create"}, tt.args...)...).ok(t)
128+ _, reqs := f.snapshot()
129+ require.Len(t, reqs, 1)
130+ got := reqs[0].(*ermite.CreateRepoRequest)
131+ assert.Equal(t, "x", got.GetName())
132+ assert.Equal(t, tt.want, got.GetVisibility())
133+ assert.Equal(t, tt.desc, got.GetDescription())
134+ })
135+ }
136+}
137+
138+func TestRepoCreateRejectsBadFlagsWithoutCalling(t *testing.T) {
139+ f := &fakeAPI{}
140+ url := f.serve(t)
141+ for _, args := range [][]string{
142+ {"repo", "create", "x", "--visibility", "secret"},
143+ {"repo", "create", "x", "--visibility", "public", "--private"},
144+ } {
145+ res := runCLI(t, envFor(url), "", args...)
146+ require.Error(t, res.err, "%v", args)
147+ }
148+ calls, _ := f.snapshot()
149+ assert.Empty(t, calls)
150+}
151+
152+func TestRepoCreateJSON(t *testing.T) {
153+ f := &fakeAPI{createRepo: func(r *ermite.CreateRepoRequest) (*ermite.CreateRepoResponse, error) {
154+ return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
155+ }}
156+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)
157+ assert.JSONEq(t, `{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null}`, res.stdout)
158+}
159+
160+func TestRepoEditMask(t *testing.T) {
161+ tests := []struct {
162+ name string
163+ args []string
164+ paths []string
165+ check func(*testing.T, *ermite.UpdateRepoRequest)
166+ }{
167+ {"description only", []string{"--description", "new"}, []string{"description"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
168+ assert.Equal(t, "new", r.GetDescription())
169+ }},
170+ {"clear description", []string{"--description", ""}, []string{"description"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
171+ assert.Empty(t, r.GetDescription())
172+ }},
173+ {"visibility", []string{"--visibility", "public"}, []string{"visibility"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
174+ assert.Equal(t, ermite.Visibility_VISIBILITY_PUBLIC, r.GetVisibility())
175+ }},
176+ {"default branch", []string{"--default-branch", "refs/heads/dev"}, []string{"default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
177+ assert.Equal(t, "dev", r.GetDefaultBranch())
178+ }},
179+ {"all", []string{"--default-branch", "dev", "--visibility", "private", "--description", "d"},
180+ []string{"description", "visibility", "default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {}},
181+ }
182+ for _, tt := range tests {
183+ t.Run(tt.name, func(t *testing.T) {
184+ f := &fakeAPI{updateRepo: func(r *ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) {
185+ return &ermite.UpdateRepoResponse{Repo: repoAlpha}, nil
186+ }}
187+ runCLI(t, envFor(f.serve(t)), "", append([]string{"repo", "edit", "alpha"}, tt.args...)...).ok(t)
188+ _, reqs := f.snapshot()
189+ require.Len(t, reqs, 1)
190+ got := reqs[0].(*ermite.UpdateRepoRequest)
191+ assert.Equal(t, "alpha", got.GetRepo())
192+ assert.ElementsMatch(t, tt.paths, got.GetUpdateMask().GetPaths())
193+ tt.check(t, got)
194+ })
195+ }
196+}
197+
198+func TestRepoEditRejects(t *testing.T) {
199+ f := &fakeAPI{}
200+ url := f.serve(t)
201+ for _, tt := range []struct {
202+ args []string
203+ want string
204+ }{
205+ {[]string{"repo", "edit", "alpha"}, "nothing to change"},
206+ {[]string{"repo", "edit", "alpha", "--visibility", "x"}, "invalid visibility"},
207+ {[]string{"repo", "edit", "alpha", "--default-branch", " "}, "must not be empty"},
208+ } {
209+ res := runCLI(t, envFor(url), "", tt.args...)
210+ require.Error(t, res.err)
211+ assert.Contains(t, res.err.Error(), tt.want)
212+ }
213+ calls, _ := f.snapshot()
214+ assert.Empty(t, calls)
215+}
216+
217+func TestRepoDeleteConfirmation(t *testing.T) {
218+ newFake := func() *fakeAPI {
219+ return &fakeAPI{deleteRepo: func(*ermite.DeleteRepoRequest) (*ermite.DeleteRepoResponse, error) {
220+ return &ermite.DeleteRepoResponse{}, nil
221+ }}
222+ }
223+ t.Run("typed name deletes", func(t *testing.T) {
224+ f := newFake()
225+ res := runCLI(t, envFor(f.serve(t)), "alpha\n", "repo", "delete", "alpha").ok(t)
226+ assert.Equal(t, "deleted alpha\n", res.stdout)
227+ assert.Contains(t, res.stderr, "Type its name to confirm")
228+ calls, reqs := f.snapshot()
229+ assert.Equal(t, []string{"DeleteRepo"}, calls)
230+ assert.Equal(t, "alpha", reqs[0].(*ermite.DeleteRepoRequest).GetRepo())
231+ })
232+ t.Run("last line without newline is accepted", func(t *testing.T) {
233+ f := newFake()
234+ runCLI(t, envFor(f.serve(t)), "alpha", "repo", "delete", "alpha").ok(t)
235+ calls, _ := f.snapshot()
236+ assert.Equal(t, []string{"DeleteRepo"}, calls)
237+ })
238+ t.Run("wrong name aborts", func(t *testing.T) {
239+ f := newFake()
240+ res := runCLI(t, envFor(f.serve(t)), "beta\n", "repo", "delete", "alpha")
241+ require.Error(t, res.err)
242+ assert.Contains(t, res.err.Error(), "did not match")
243+ calls, _ := f.snapshot()
244+ assert.Empty(t, calls)
245+ })
246+ t.Run("empty stdin aborts", func(t *testing.T) {
247+ f := newFake()
248+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "delete", "alpha")
249+ require.Error(t, res.err)
250+ assert.Contains(t, res.err.Error(), "--yes")
251+ calls, _ := f.snapshot()
252+ assert.Empty(t, calls)
253+ })
254+ t.Run("--yes skips the question", func(t *testing.T) {
255+ f := newFake()
256+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "delete", "alpha", "--yes").ok(t)
257+ assert.Equal(t, "deleted alpha\n", res.stdout)
258+ assert.NotContains(t, res.stderr, "confirm")
259+ })
260+ t.Run("-y", func(t *testing.T) {
261+ f := newFake()
262+ runCLI(t, envFor(f.serve(t)), "", "repo", "delete", "alpha", "-y").ok(t)
263+ })
264+}
265+
266+func TestRepoErrorsAreFriendly(t *testing.T) {
267+ f := &fakeAPI{getRepo: func(*ermite.GetRepoRequest) (*ermite.GetRepoResponse, error) {
268+ return nil, connect.NewError(connect.CodeNotFound, errors.New("repository \"nope\" not found"))
269+ }}
270+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "show", "nope")
271+ require.Error(t, res.err)
272+ assert.Equal(t, "not found: repository \"nope\" not found", res.err.Error())
273+}
274+
275+func TestUnauthenticatedHintsAtToken(t *testing.T) {
276+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
277+ return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid token"))
278+ }}
279+ res := runCLI(t, map[string]string{"ERMITE_URL": f.serve(t)}, "", "repo", "list")
280+ require.Error(t, res.err)
281+ assert.Contains(t, res.err.Error(), "ERMITE_TOKEN")
282+}
283+
284+func TestMissingURL(t *testing.T) {
285+ res := runCLI(t, nil, "", "repo", "list")
286+ require.Error(t, res.err)
287+ assert.Contains(t, res.err.Error(), "ERMITE_URL is not set")
288+}
289+
290+func TestInvalidURL(t *testing.T) {
291+ res := runCLI(t, map[string]string{"ERMITE_URL": "git.example.com"}, "", "repo", "list")
292+ require.Error(t, res.err)
293+ assert.Contains(t, res.err.Error(), "ERMITE_URL")
294+ assert.Contains(t, res.err.Error(), "http")
295+}
296+
297+func TestUnreachableServer(t *testing.T) {
298+ res := runCLI(t, envFor("http://127.0.0.1:1"), "", "repo", "list")
299+ require.Error(t, res.err)
300+ assert.Contains(t, res.err.Error(), "cannot reach the server at http://127.0.0.1:1")
301+ assert.NotContains(t, res.err.Error(), "\n")
302+}
303+
304+func TestInsecureTokenWarningOnStderr(t *testing.T) {
305+ // A non-loopback http URL: nothing listens there, the warning comes first.
306+ res := runCLI(t, map[string]string{"ERMITE_URL": "http://ermite.invalid:1", "ERMITE_TOKEN": "ermite_tok"}, "", "repo", "list")
307+ require.Error(t, res.err)
308+ assert.Contains(t, res.stderr, "ermite: warning: ERMITE_TOKEN is sent over plain http to ermite.invalid")
309+ assert.NotContains(t, res.stderr, "ermite_tok")
310+}
311+
312+func TestLoopbackHTTPDoesNotWarn(t *testing.T) {
313+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
314+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
315+ }}
316+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "list").ok(t)
317+ assert.Empty(t, res.stderr)
318+}
319+
320+func TestTerminalControlCharactersAreNeutralised(t *testing.T) {
321+ evil := &ermite.Repo{Name: "x", Description: "a\x1b[31mred\nline", Visibility: ermite.Visibility_VISIBILITY_PUBLIC}
322+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
323+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{evil}}, nil
324+ }}
325+ res := runCLI(t, envFor(f.serve(t)), "", "repo", "list").ok(t)
326+ assert.NotContains(t, res.stdout, "\x1b")
327+ assert.Equal(t, 2, len(splitLines(res.stdout)), "header and one row")
328+}
329+
330+func splitLines(s string) []string {
331+ var out []string
332+ cur := ""
333+ for _, r := range s {
334+ if r == '\n' {
335+ out = append(out, cur)
336+ cur = ""
337+ continue
338+ }
339+ cur += string(r)
340+ }
341+ if cur != "" {
342+ out = append(out, cur)
343+ }
344+ return out
345+}
346+
347+func TestRepoNameCompletion(t *testing.T) {
348+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
349+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil
350+ }}
351+ vars := envFor(f.serve(t))
352+ res := runCLI(t, vars, "", "__complete", "repo", "show", "a").ok(t)
353+ assert.Contains(t, res.stdout, "alpha\tpublic, The first one")
354+ assert.NotContains(t, res.stdout, "beta")
355+
356+ res = runCLI(t, vars, "", "__complete", "repo", "delete", "").ok(t)
357+ assert.Contains(t, res.stdout, "alpha")
358+ assert.Contains(t, res.stdout, "beta")
359+
360+ // A second argument gets no repository names.
361+ res = runCLI(t, vars, "", "__complete", "repo", "show", "alpha", "").ok(t)
362+ assert.NotContains(t, res.stdout, "beta")
363+
364+ res = runCLI(t, vars, "", "__complete", "watch", "").ok(t)
365+ assert.Contains(t, res.stdout, "alpha")
366+}
367+
368+func TestCompletionIsSilentOnErrors(t *testing.T) {
369+ denied := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
370+ return nil, connect.NewError(connect.CodeUnauthenticated, nil)
371+ }}
372+ for name, vars := range map[string]map[string]string{
373+ "no config": nil,
374+ "bad url": {"ERMITE_URL": "nope"},
375+ "unreachable": envFor("http://ermite.invalid:1"),
376+ "rejected": envFor(denied.serve(t)),
377+ } {
378+ t.Run(name, func(t *testing.T) {
379+ res := runCLI(t, vars, "", "__complete", "repo", "show", "").ok(t)
380+ assert.NotContains(t, res.stdout, "alpha")
381+ assert.NotContains(t, res.stderr, "warning", "completion must not print warnings")
382+ assert.Contains(t, res.stdout, ":4", "cobra's no-file-completion directive")
383+ })
384+ }
385+}
386+
387+func TestShellCompletionScriptIsAvailable(t *testing.T) {
388+ res := runCLI(t, nil, "", "completion", "bash").ok(t)
389+ assert.Contains(t, res.stdout, "bash completion")
390+}
391+
392+func TestToJSONRepoFromProto(t *testing.T) {
393+ r := toJSONRepo(proto.Clone(repoBeta).(*ermite.Repo))
394+ assert.Nil(t, r.PushedAt)
395+ assert.Equal(t, "private", r.Visibility)
396+ assert.Equal(t, "unknown", toJSONRepo(&ermite.Repo{}).Visibility)
397+}
A cmd/ermite/cli_token.go +271 -0
@@ -0,0 +1,271 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strings"
11+ "time"
12+
13+ "connectrpc.com/connect"
14+ "github.com/spf13/cobra"
15+ "google.golang.org/protobuf/types/known/timestamppb"
16+
17+ "bnema.dev/ermite-api/gen/ermite"
18+ "bnema.dev/ermite/internal/adapters/client"
19+)
20+
21+// jsonToken is the stable JSON shape of a stored token. The secret is only
22+// present in the output of `token create`.
23+type jsonToken struct {
24+ ID string `json:"id"`
25+ Label string `json:"label"`
26+ Scopes []string `json:"scopes"`
27+ Repos []string `json:"repos"`
28+ ExpiresAt *string `json:"expires_at"`
29+ CreatedAt *string `json:"created_at"`
30+ LastUsedAt *string `json:"last_used_at"`
31+}
32+
33+type jsonCreatedToken struct {
34+ jsonToken
35+ Secret string `json:"secret"`
36+}
37+
38+func toJSONToken(t *ermite.Token) jsonToken {
39+ scopes := make([]string, 0, len(t.GetScopes()))
40+ for _, s := range t.GetScopes() {
41+ scopes = append(scopes, client.ScopeName(s))
42+ }
43+ repos := t.GetRepos()
44+ if repos == nil {
45+ repos = []string{}
46+ }
47+ return jsonToken{
48+ ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos,
49+ ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()),
50+ }
51+}
52+
53+const jsonTokenHelp = `
54+JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"),
55+repos (array; empty means every repository), expires_at, created_at and last_used_at
56+(RFC 3339 UTC, null when unset or never used).`
57+
58+func newTokenCmd(env *cliEnv) *cobra.Command {
59+ token := &cobra.Command{
60+ Use: "token",
61+ Short: "Manage API tokens on a remote server",
62+ Long: "Create, list and revoke API tokens. Needs the token:admin scope (or the server's admin token).",
63+ }
64+ token.AddCommand(newTokenCreateCmd(env), newTokenListCmd(env), newTokenRevokeCmd(env))
65+ return token
66+}
67+
68+func newTokenCreateCmd(env *cliEnv) *cobra.Command {
69+ var (
70+ preset string
71+ scopes []string
72+ repos []string
73+ expires string
74+ )
75+ cmd := &cobra.Command{
76+ Use: "create <label>",
77+ Short: "Create a token and print its secret once",
78+ Long: "Create a token. The secret is printed once on standard output (and nowhere else: pipe it\n" +
79+ "or store it right away); details go to standard error. With --json, standard output is one\n" +
80+ "object holding the token fields and the secret.\n\n" +
81+ "What the token may do is chosen with --preset and/or --scope (they add up); there is no default:\n" +
82+ " cli every scope, for your own daily use\n" +
83+ " agent repo:write, mr:write\n" +
84+ " ci repo:read\n" +
85+ "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +
86+ "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never." + jsonTokenHelp,
87+ Args: cobra.ExactArgs(1),
88+ }
89+ cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", "))
90+ cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)")
91+ cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)")
92+ cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never")
93+ _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
94+ return client.PresetNames, cobra.ShellCompDirectiveNoFileComp
95+ })
96+ _ = cmd.RegisterFlagCompletionFunc("scope", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
97+ return client.ScopeNames(client.AllScopes()), cobra.ShellCompDirectiveNoFileComp
98+ })
99+ _ = cmd.RegisterFlagCompletionFunc("repo", func(c *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
100+ names, err := repoNames(env.ctx(c), env, c, toComplete)
101+ if err != nil {
102+ return nil, cobra.ShellCompDirectiveNoFileComp
103+ }
104+ return names, cobra.ShellCompDirectiveNoFileComp
105+ })
106+ asJSON := addJSONFlag(cmd)
107+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
108+ req, err := buildCreateToken(args[0], preset, scopes, repos, expires, env.now())
109+ if err != nil {
110+ return err
111+ }
112+ c, err := env.client(cmd, true)
113+ if err != nil {
114+ return err
115+ }
116+ res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
117+ if err != nil {
118+ return c.Describe(err)
119+ }
120+ return printCreatedToken(cmd, res.Msg, *asJSON)
121+ }
122+ return cmd
123+}
124+
125+// buildCreateToken maps the flags of `token create` to the request.
126+func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) {
127+ label = strings.TrimSpace(label)
128+ if label == "" {
129+ return nil, errors.New("the label must not be empty")
130+ }
131+ scopes, err := client.MergeScopes(preset, scopeFlags)
132+ if err != nil {
133+ return nil, err
134+ }
135+ req := &ermite.CreateTokenRequest{Label: label, Scopes: scopes}
136+ for _, r := range repoFlags {
137+ if r = strings.TrimSpace(r); r != "" && !slices.Contains(req.Repos, r) {
138+ req.Repos = append(req.Repos, r)
139+ }
140+ }
141+ if expires != "" {
142+ at, err := client.ParseExpiry(expires, now)
143+ if err != nil {
144+ return nil, err
145+ }
146+ req.ExpiresAt = timestamppb.New(at)
147+ }
148+ return req, nil
149+}
150+
151+func printCreatedToken(cmd *cobra.Command, res *ermite.CreateTokenResponse, asJSON bool) error {
152+ if asJSON {
153+ return writeJSON(cmd.OutOrStdout(), jsonCreatedToken{jsonToken: toJSONToken(res.GetToken()), Secret: res.GetSecret()})
154+ }
155+ t := res.GetToken()
156+ repos := "all"
157+ if len(t.GetRepos()) > 0 {
158+ repos = strings.Join(t.GetRepos(), ", ")
159+ }
160+ noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel()))
161+ _ = fields(cmd.ErrOrStderr(), [][2]string{
162+ {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")},
163+ {" repos", repos},
164+ {" expires", shortTime(t.GetExpiresAt(), "never")},
165+ })
166+ noticef(cmd, "the secret below is shown once and cannot be retrieved later")
167+ _, err := fmt.Fprintln(cmd.OutOrStdout(), res.GetSecret())
168+ return err
169+}
170+
171+func listTokens(ctx context.Context, c *client.Client) ([]*ermite.Token, error) {
172+ var tokens []*ermite.Token
173+ page := ""
174+ for {
175+ res, err := c.Tokens().ListTokens(ctx, connect.NewRequest(&ermite.ListTokensRequest{PageToken: page}))
176+ if err != nil {
177+ return nil, c.Describe(err)
178+ }
179+ tokens = append(tokens, res.Msg.GetTokens()...)
180+ page = res.Msg.GetNextPageToken()
181+ if page == "" {
182+ return tokens, nil
183+ }
184+ }
185+}
186+
187+func newTokenListCmd(env *cliEnv) *cobra.Command {
188+ cmd := &cobra.Command{
189+ Use: "list",
190+ Short: "List stored tokens",
191+ Long: "List stored tokens, newest first. Secrets are never shown. The server's admin token is not listed." + jsonTokenHelp + "\n--json prints an array of tokens.",
192+ Args: cobra.NoArgs,
193+ }
194+ asJSON := addJSONFlag(cmd)
195+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
196+ c, err := env.client(cmd, true)
197+ if err != nil {
198+ return err
199+ }
200+ tokens, err := listTokens(env.ctx(cmd), c)
201+ if err != nil {
202+ return err
203+ }
204+ if *asJSON {
205+ out := make([]jsonToken, 0, len(tokens))
206+ for _, t := range tokens {
207+ out = append(out, toJSONToken(t))
208+ }
209+ return writeJSON(cmd.OutOrStdout(), out)
210+ }
211+ if len(tokens) == 0 {
212+ noticef(cmd, "no tokens")
213+ return nil
214+ }
215+ rows := make([][]string, 0, len(tokens))
216+ for _, t := range tokens {
217+ repos := "all"
218+ if len(t.GetRepos()) > 0 {
219+ repos = strings.Join(t.GetRepos(), ",")
220+ }
221+ rows = append(rows, []string{
222+ t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos,
223+ shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"),
224+ })
225+ }
226+ return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "EXPIRES", "LAST USED"}, rows)
227+ }
228+ return cmd
229+}
230+
231+func newTokenRevokeCmd(env *cliEnv) *cobra.Command {
232+ return &cobra.Command{
233+ Use: "revoke <id>",
234+ Short: "Revoke a token",
235+ Long: "Revoke a token by id (see `ermite token list`). It stops working immediately.",
236+ Args: cobra.ExactArgs(1),
237+ ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
238+ if len(args) > 0 {
239+ return nil, cobra.ShellCompDirectiveNoFileComp
240+ }
241+ c, err := env.client(cmd, false)
242+ if err != nil {
243+ return nil, cobra.ShellCompDirectiveNoFileComp
244+ }
245+ ctx, cancel := context.WithTimeout(env.ctx(cmd), 3*time.Second)
246+ defer cancel()
247+ tokens, err := listTokens(ctx, c)
248+ if err != nil {
249+ return nil, cobra.ShellCompDirectiveNoFileComp
250+ }
251+ var ids []string
252+ for _, t := range tokens {
253+ if strings.HasPrefix(t.GetId(), toComplete) {
254+ ids = append(ids, t.GetId()+"\t"+clean(t.GetLabel()))
255+ }
256+ }
257+ return ids, cobra.ShellCompDirectiveNoFileComp
258+ },
259+ RunE: func(cmd *cobra.Command, args []string) error {
260+ c, err := env.client(cmd, true)
261+ if err != nil {
262+ return err
263+ }
264+ if _, err := c.Tokens().RevokeToken(env.ctx(cmd), connect.NewRequest(&ermite.RevokeTokenRequest{Id: args[0]})); err != nil {
265+ return c.Describe(err)
266+ }
267+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "revoked %s\n", args[0])
268+ return err
269+ },
270+ }
271+}
@@ -0,0 +1,271 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strings"
11+ "time"
12+
13+ "connectrpc.com/connect"
14+ "github.com/spf13/cobra"
15+ "google.golang.org/protobuf/types/known/timestamppb"
16+
17+ "bnema.dev/ermite-api/gen/ermite"
18+ "bnema.dev/ermite/internal/adapters/client"
19+)
20+
21+// jsonToken is the stable JSON shape of a stored token. The secret is only
22+// present in the output of `token create`.
23+type jsonToken struct {
24+ ID string `json:"id"`
25+ Label string `json:"label"`
26+ Scopes []string `json:"scopes"`
27+ Repos []string `json:"repos"`
28+ ExpiresAt *string `json:"expires_at"`
29+ CreatedAt *string `json:"created_at"`
30+ LastUsedAt *string `json:"last_used_at"`
31+}
32+
33+type jsonCreatedToken struct {
34+ jsonToken
35+ Secret string `json:"secret"`
36+}
37+
38+func toJSONToken(t *ermite.Token) jsonToken {
39+ scopes := make([]string, 0, len(t.GetScopes()))
40+ for _, s := range t.GetScopes() {
41+ scopes = append(scopes, client.ScopeName(s))
42+ }
43+ repos := t.GetRepos()
44+ if repos == nil {
45+ repos = []string{}
46+ }
47+ return jsonToken{
48+ ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos,
49+ ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()),
50+ }
51+}
52+
53+const jsonTokenHelp = `
54+JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"),
55+repos (array; empty means every repository), expires_at, created_at and last_used_at
56+(RFC 3339 UTC, null when unset or never used).`
57+
58+func newTokenCmd(env *cliEnv) *cobra.Command {
59+ token := &cobra.Command{
60+ Use: "token",
61+ Short: "Manage API tokens on a remote server",
62+ Long: "Create, list and revoke API tokens. Needs the token:admin scope (or the server's admin token).",
63+ }
64+ token.AddCommand(newTokenCreateCmd(env), newTokenListCmd(env), newTokenRevokeCmd(env))
65+ return token
66+}
67+
68+func newTokenCreateCmd(env *cliEnv) *cobra.Command {
69+ var (
70+ preset string
71+ scopes []string
72+ repos []string
73+ expires string
74+ )
75+ cmd := &cobra.Command{
76+ Use: "create <label>",
77+ Short: "Create a token and print its secret once",
78+ Long: "Create a token. The secret is printed once on standard output (and nowhere else: pipe it\n" +
79+ "or store it right away); details go to standard error. With --json, standard output is one\n" +
80+ "object holding the token fields and the secret.\n\n" +
81+ "What the token may do is chosen with --preset and/or --scope (they add up); there is no default:\n" +
82+ " cli every scope, for your own daily use\n" +
83+ " agent repo:write, mr:write\n" +
84+ " ci repo:read\n" +
85+ "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +
86+ "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never." + jsonTokenHelp,
87+ Args: cobra.ExactArgs(1),
88+ }
89+ cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", "))
90+ cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)")
91+ cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)")
92+ cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never")
93+ _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
94+ return client.PresetNames, cobra.ShellCompDirectiveNoFileComp
95+ })
96+ _ = cmd.RegisterFlagCompletionFunc("scope", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
97+ return client.ScopeNames(client.AllScopes()), cobra.ShellCompDirectiveNoFileComp
98+ })
99+ _ = cmd.RegisterFlagCompletionFunc("repo", func(c *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
100+ names, err := repoNames(env.ctx(c), env, c, toComplete)
101+ if err != nil {
102+ return nil, cobra.ShellCompDirectiveNoFileComp
103+ }
104+ return names, cobra.ShellCompDirectiveNoFileComp
105+ })
106+ asJSON := addJSONFlag(cmd)
107+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
108+ req, err := buildCreateToken(args[0], preset, scopes, repos, expires, env.now())
109+ if err != nil {
110+ return err
111+ }
112+ c, err := env.client(cmd, true)
113+ if err != nil {
114+ return err
115+ }
116+ res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
117+ if err != nil {
118+ return c.Describe(err)
119+ }
120+ return printCreatedToken(cmd, res.Msg, *asJSON)
121+ }
122+ return cmd
123+}
124+
125+// buildCreateToken maps the flags of `token create` to the request.
126+func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) {
127+ label = strings.TrimSpace(label)
128+ if label == "" {
129+ return nil, errors.New("the label must not be empty")
130+ }
131+ scopes, err := client.MergeScopes(preset, scopeFlags)
132+ if err != nil {
133+ return nil, err
134+ }
135+ req := &ermite.CreateTokenRequest{Label: label, Scopes: scopes}
136+ for _, r := range repoFlags {
137+ if r = strings.TrimSpace(r); r != "" && !slices.Contains(req.Repos, r) {
138+ req.Repos = append(req.Repos, r)
139+ }
140+ }
141+ if expires != "" {
142+ at, err := client.ParseExpiry(expires, now)
143+ if err != nil {
144+ return nil, err
145+ }
146+ req.ExpiresAt = timestamppb.New(at)
147+ }
148+ return req, nil
149+}
150+
151+func printCreatedToken(cmd *cobra.Command, res *ermite.CreateTokenResponse, asJSON bool) error {
152+ if asJSON {
153+ return writeJSON(cmd.OutOrStdout(), jsonCreatedToken{jsonToken: toJSONToken(res.GetToken()), Secret: res.GetSecret()})
154+ }
155+ t := res.GetToken()
156+ repos := "all"
157+ if len(t.GetRepos()) > 0 {
158+ repos = strings.Join(t.GetRepos(), ", ")
159+ }
160+ noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel()))
161+ _ = fields(cmd.ErrOrStderr(), [][2]string{
162+ {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")},
163+ {" repos", repos},
164+ {" expires", shortTime(t.GetExpiresAt(), "never")},
165+ })
166+ noticef(cmd, "the secret below is shown once and cannot be retrieved later")
167+ _, err := fmt.Fprintln(cmd.OutOrStdout(), res.GetSecret())
168+ return err
169+}
170+
171+func listTokens(ctx context.Context, c *client.Client) ([]*ermite.Token, error) {
172+ var tokens []*ermite.Token
173+ page := ""
174+ for {
175+ res, err := c.Tokens().ListTokens(ctx, connect.NewRequest(&ermite.ListTokensRequest{PageToken: page}))
176+ if err != nil {
177+ return nil, c.Describe(err)
178+ }
179+ tokens = append(tokens, res.Msg.GetTokens()...)
180+ page = res.Msg.GetNextPageToken()
181+ if page == "" {
182+ return tokens, nil
183+ }
184+ }
185+}
186+
187+func newTokenListCmd(env *cliEnv) *cobra.Command {
188+ cmd := &cobra.Command{
189+ Use: "list",
190+ Short: "List stored tokens",
191+ Long: "List stored tokens, newest first. Secrets are never shown. The server's admin token is not listed." + jsonTokenHelp + "\n--json prints an array of tokens.",
192+ Args: cobra.NoArgs,
193+ }
194+ asJSON := addJSONFlag(cmd)
195+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
196+ c, err := env.client(cmd, true)
197+ if err != nil {
198+ return err
199+ }
200+ tokens, err := listTokens(env.ctx(cmd), c)
201+ if err != nil {
202+ return err
203+ }
204+ if *asJSON {
205+ out := make([]jsonToken, 0, len(tokens))
206+ for _, t := range tokens {
207+ out = append(out, toJSONToken(t))
208+ }
209+ return writeJSON(cmd.OutOrStdout(), out)
210+ }
211+ if len(tokens) == 0 {
212+ noticef(cmd, "no tokens")
213+ return nil
214+ }
215+ rows := make([][]string, 0, len(tokens))
216+ for _, t := range tokens {
217+ repos := "all"
218+ if len(t.GetRepos()) > 0 {
219+ repos = strings.Join(t.GetRepos(), ",")
220+ }
221+ rows = append(rows, []string{
222+ t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos,
223+ shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"),
224+ })
225+ }
226+ return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "EXPIRES", "LAST USED"}, rows)
227+ }
228+ return cmd
229+}
230+
231+func newTokenRevokeCmd(env *cliEnv) *cobra.Command {
232+ return &cobra.Command{
233+ Use: "revoke <id>",
234+ Short: "Revoke a token",
235+ Long: "Revoke a token by id (see `ermite token list`). It stops working immediately.",
236+ Args: cobra.ExactArgs(1),
237+ ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
238+ if len(args) > 0 {
239+ return nil, cobra.ShellCompDirectiveNoFileComp
240+ }
241+ c, err := env.client(cmd, false)
242+ if err != nil {
243+ return nil, cobra.ShellCompDirectiveNoFileComp
244+ }
245+ ctx, cancel := context.WithTimeout(env.ctx(cmd), 3*time.Second)
246+ defer cancel()
247+ tokens, err := listTokens(ctx, c)
248+ if err != nil {
249+ return nil, cobra.ShellCompDirectiveNoFileComp
250+ }
251+ var ids []string
252+ for _, t := range tokens {
253+ if strings.HasPrefix(t.GetId(), toComplete) {
254+ ids = append(ids, t.GetId()+"\t"+clean(t.GetLabel()))
255+ }
256+ }
257+ return ids, cobra.ShellCompDirectiveNoFileComp
258+ },
259+ RunE: func(cmd *cobra.Command, args []string) error {
260+ c, err := env.client(cmd, true)
261+ if err != nil {
262+ return err
263+ }
264+ if _, err := c.Tokens().RevokeToken(env.ctx(cmd), connect.NewRequest(&ermite.RevokeTokenRequest{Id: args[0]})); err != nil {
265+ return c.Describe(err)
266+ }
267+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "revoked %s\n", args[0])
268+ return err
269+ },
270+ }
271+}
A cmd/ermite/cli_token_test.go +151 -0
@@ -0,0 +1,151 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/adapters/client"
14+)
15+
16+func TestTokenCreateMapsFlags(t *testing.T) {
17+ f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
18+ return &ermite.CreateTokenResponse{
19+ Token: &ermite.Token{
20+ Id: "tok1", Label: r.GetLabel(), Scopes: r.GetScopes(), Repos: r.GetRepos(),
21+ ExpiresAt: r.GetExpiresAt(), CreatedAt: ts("2026-10-10T12:00:00Z"),
22+ },
23+ Secret: "ermite_SECRETVALUE",
24+ }, nil
25+ }}
26+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "ci runner",
27+ "--preset", "ci", "--scope", "repo:write,mr:write", "--repo", "alpha", "--repo", "beta", "--repo", "alpha", "--expires", "30d").ok(t)
28+
29+ _, reqs := f.snapshot()
30+ got := reqs[0].(*ermite.CreateTokenRequest)
31+ assert.Equal(t, "ci runner", got.GetLabel())
32+ assert.Equal(t, []ermite.Scope{ermite.Scope_SCOPE_REPO_READ, ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, got.GetScopes())
33+ assert.Equal(t, []string{"alpha", "beta"}, got.GetRepos())
34+ assert.Equal(t, testNow.Add(30*24*time.Hour), got.GetExpiresAt().AsTime())
35+
36+ // The secret alone is on stdout, details are on stderr.
37+ assert.Equal(t, "ermite_SECRETVALUE\n", res.stdout)
38+ assert.Contains(t, res.stderr, `created token tok1 "ci runner"`)
39+ assert.Contains(t, res.stderr, "repo:read, repo:write, mr:write")
40+ assert.Contains(t, res.stderr, "alpha, beta")
41+ assert.Contains(t, res.stderr, "shown once")
42+ assert.NotContains(t, res.stderr, "SECRETVALUE")
43+}
44+
45+func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {
46+ for preset, want := range map[string][]ermite.Scope{
47+ "agent": {ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE},
48+ "ci": {ermite.Scope_SCOPE_REPO_READ},
49+ "cli": client.AllScopes(),
50+ } {
51+ t.Run(preset, func(t *testing.T) {
52+ f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
53+ return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil
54+ }}
55+ runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t)
56+ _, reqs := f.snapshot()
57+ got := reqs[0].(*ermite.CreateTokenRequest)
58+ assert.ElementsMatch(t, want, got.GetScopes())
59+ assert.Empty(t, got.GetRepos())
60+ assert.Nil(t, got.GetExpiresAt())
61+ })
62+ }
63+}
64+
65+func TestTokenCreateJSON(t *testing.T) {
66+ f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
67+ return &ermite.CreateTokenResponse{
68+ Token: &ermite.Token{Id: "tok1", Label: "l", Scopes: r.GetScopes(), CreatedAt: ts("2026-10-10T12:00:00Z")},
69+ Secret: "ermite_S",
70+ }, nil
71+ }}
72+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t)
73+ assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)
74+ assert.Empty(t, res.stderr)
75+}
76+
77+func TestTokenCreateValidatesBeforeCalling(t *testing.T) {
78+ f := &fakeAPI{}
79+ url := f.serve(t)
80+ for _, tt := range []struct {
81+ args []string
82+ want string
83+ }{
84+ {[]string{"x"}, "--preset"},
85+ {[]string{"x", "--preset", "god"}, "unknown preset"},
86+ {[]string{"x", "--scope", "nope"}, "unknown scope"},
87+ {[]string{"x", "--preset", "ci", "--expires", "yesterday"}, "--expires"},
88+ {[]string{" ", "--preset", "ci"}, "label"},
89+ } {
90+ res := runCLI(t, envFor(url), "", append([]string{"token", "create"}, tt.args...)...)
91+ require.Error(t, res.err, "%v", tt.args)
92+ assert.Contains(t, res.err.Error(), tt.want)
93+ }
94+ calls, _ := f.snapshot()
95+ assert.Empty(t, calls)
96+}
97+
98+func TestTokenList(t *testing.T) {
99+ f := &fakeAPI{listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) {
100+ return &ermite.ListTokensResponse{Tokens: []*ermite.Token{
101+ {Id: "t2", Label: "laptop", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ, ermite.Scope_SCOPE_REPO_WRITE},
102+ Repos: []string{"alpha", "beta"}, ExpiresAt: ts("2027-01-01T00:00:00Z"), CreatedAt: ts("2026-10-01T00:00:00Z"), LastUsedAt: ts("2026-10-09T08:30:00Z")},
103+ {Id: "t1", Label: "ci", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, CreatedAt: ts("2026-09-01T00:00:00Z")},
104+ }}, nil
105+ }}
106+ url := f.serve(t)
107+ res := runCLI(t, envFor(url), "", "token", "list").ok(t)
108+ assert.Equal(t, ""+
109+ "ID LABEL SCOPES REPOS EXPIRES LAST USED\n"+
110+ "t2 laptop repo:read,repo:write alpha,beta 2027-01-01 00:00 2026-10-09 08:30\n"+
111+ "t1 ci repo:read all never never\n", res.stdout)
112+
113+ res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t)
114+ assert.JSONEq(t, `[
115+ {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"},
116+ {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
117+ ]`, res.stdout)
118+ assert.NotContains(t, res.stdout, "secret")
119+}
120+
121+func TestTokenRevoke(t *testing.T) {
122+ f := &fakeAPI{revokeToken: func(*ermite.RevokeTokenRequest) (*ermite.RevokeTokenResponse, error) {
123+ return &ermite.RevokeTokenResponse{}, nil
124+ }}
125+ res := runCLI(t, envFor(f.serve(t)), "", "token", "revoke", "t2").ok(t)
126+ assert.Equal(t, "revoked t2\n", res.stdout)
127+ _, reqs := f.snapshot()
128+ assert.Equal(t, "t2", reqs[0].(*ermite.RevokeTokenRequest).GetId())
129+}
130+
131+func TestTokenCompletion(t *testing.T) {
132+ f := &fakeAPI{
133+ listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) {
134+ return &ermite.ListTokensResponse{Tokens: []*ermite.Token{{Id: "abc1", Label: "laptop"}, {Id: "xyz2", Label: "ci"}}}, nil
135+ },
136+ listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
137+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
138+ },
139+ }
140+ vars := envFor(f.serve(t))
141+ res := runCLI(t, vars, "", "__complete", "token", "revoke", "a").ok(t)
142+ assert.Contains(t, res.stdout, "abc1\tlaptop")
143+ assert.NotContains(t, res.stdout, "xyz2")
144+
145+ res = runCLI(t, vars, "", "__complete", "token", "create", "x", "--preset", "").ok(t)
146+ assert.Contains(t, res.stdout, "agent")
147+ res = runCLI(t, vars, "", "__complete", "token", "create", "x", "--scope", "repo:").ok(t)
148+ assert.Contains(t, res.stdout, "repo:read")
149+ res = runCLI(t, vars, "", "__complete", "token", "create", "x", "--repo", "").ok(t)
150+ assert.Contains(t, res.stdout, "alpha")
151+}
@@ -0,0 +1,151 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/adapters/client"
14+)
15+
16+func TestTokenCreateMapsFlags(t *testing.T) {
17+ f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
18+ return &ermite.CreateTokenResponse{
19+ Token: &ermite.Token{
20+ Id: "tok1", Label: r.GetLabel(), Scopes: r.GetScopes(), Repos: r.GetRepos(),
21+ ExpiresAt: r.GetExpiresAt(), CreatedAt: ts("2026-10-10T12:00:00Z"),
22+ },
23+ Secret: "ermite_SECRETVALUE",
24+ }, nil
25+ }}
26+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "ci runner",
27+ "--preset", "ci", "--scope", "repo:write,mr:write", "--repo", "alpha", "--repo", "beta", "--repo", "alpha", "--expires", "30d").ok(t)
28+
29+ _, reqs := f.snapshot()
30+ got := reqs[0].(*ermite.CreateTokenRequest)
31+ assert.Equal(t, "ci runner", got.GetLabel())
32+ assert.Equal(t, []ermite.Scope{ermite.Scope_SCOPE_REPO_READ, ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, got.GetScopes())
33+ assert.Equal(t, []string{"alpha", "beta"}, got.GetRepos())
34+ assert.Equal(t, testNow.Add(30*24*time.Hour), got.GetExpiresAt().AsTime())
35+
36+ // The secret alone is on stdout, details are on stderr.
37+ assert.Equal(t, "ermite_SECRETVALUE\n", res.stdout)
38+ assert.Contains(t, res.stderr, `created token tok1 "ci runner"`)
39+ assert.Contains(t, res.stderr, "repo:read, repo:write, mr:write")
40+ assert.Contains(t, res.stderr, "alpha, beta")
41+ assert.Contains(t, res.stderr, "shown once")
42+ assert.NotContains(t, res.stderr, "SECRETVALUE")
43+}
44+
45+func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {
46+ for preset, want := range map[string][]ermite.Scope{
47+ "agent": {ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE},
48+ "ci": {ermite.Scope_SCOPE_REPO_READ},
49+ "cli": client.AllScopes(),
50+ } {
51+ t.Run(preset, func(t *testing.T) {
52+ f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
53+ return &ermite.CreateTokenResponse{Token: &ermite.Token{Id: "t", Scopes: r.GetScopes()}, Secret: "s"}, nil
54+ }}
55+ runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", preset).ok(t)
56+ _, reqs := f.snapshot()
57+ got := reqs[0].(*ermite.CreateTokenRequest)
58+ assert.ElementsMatch(t, want, got.GetScopes())
59+ assert.Empty(t, got.GetRepos())
60+ assert.Nil(t, got.GetExpiresAt())
61+ })
62+ }
63+}
64+
65+func TestTokenCreateJSON(t *testing.T) {
66+ f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
67+ return &ermite.CreateTokenResponse{
68+ Token: &ermite.Token{Id: "tok1", Label: "l", Scopes: r.GetScopes(), CreatedAt: ts("2026-10-10T12:00:00Z")},
69+ Secret: "ermite_S",
70+ }, nil
71+ }}
72+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t)
73+ assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)
74+ assert.Empty(t, res.stderr)
75+}
76+
77+func TestTokenCreateValidatesBeforeCalling(t *testing.T) {
78+ f := &fakeAPI{}
79+ url := f.serve(t)
80+ for _, tt := range []struct {
81+ args []string
82+ want string
83+ }{
84+ {[]string{"x"}, "--preset"},
85+ {[]string{"x", "--preset", "god"}, "unknown preset"},
86+ {[]string{"x", "--scope", "nope"}, "unknown scope"},
87+ {[]string{"x", "--preset", "ci", "--expires", "yesterday"}, "--expires"},
88+ {[]string{" ", "--preset", "ci"}, "label"},
89+ } {
90+ res := runCLI(t, envFor(url), "", append([]string{"token", "create"}, tt.args...)...)
91+ require.Error(t, res.err, "%v", tt.args)
92+ assert.Contains(t, res.err.Error(), tt.want)
93+ }
94+ calls, _ := f.snapshot()
95+ assert.Empty(t, calls)
96+}
97+
98+func TestTokenList(t *testing.T) {
99+ f := &fakeAPI{listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) {
100+ return &ermite.ListTokensResponse{Tokens: []*ermite.Token{
101+ {Id: "t2", Label: "laptop", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ, ermite.Scope_SCOPE_REPO_WRITE},
102+ Repos: []string{"alpha", "beta"}, ExpiresAt: ts("2027-01-01T00:00:00Z"), CreatedAt: ts("2026-10-01T00:00:00Z"), LastUsedAt: ts("2026-10-09T08:30:00Z")},
103+ {Id: "t1", Label: "ci", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, CreatedAt: ts("2026-09-01T00:00:00Z")},
104+ }}, nil
105+ }}
106+ url := f.serve(t)
107+ res := runCLI(t, envFor(url), "", "token", "list").ok(t)
108+ assert.Equal(t, ""+
109+ "ID LABEL SCOPES REPOS EXPIRES LAST USED\n"+
110+ "t2 laptop repo:read,repo:write alpha,beta 2027-01-01 00:00 2026-10-09 08:30\n"+
111+ "t1 ci repo:read all never never\n", res.stdout)
112+
113+ res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t)
114+ assert.JSONEq(t, `[
115+ {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"},
116+ {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
117+ ]`, res.stdout)
118+ assert.NotContains(t, res.stdout, "secret")
119+}
120+
121+func TestTokenRevoke(t *testing.T) {
122+ f := &fakeAPI{revokeToken: func(*ermite.RevokeTokenRequest) (*ermite.RevokeTokenResponse, error) {
123+ return &ermite.RevokeTokenResponse{}, nil
124+ }}
125+ res := runCLI(t, envFor(f.serve(t)), "", "token", "revoke", "t2").ok(t)
126+ assert.Equal(t, "revoked t2\n", res.stdout)
127+ _, reqs := f.snapshot()
128+ assert.Equal(t, "t2", reqs[0].(*ermite.RevokeTokenRequest).GetId())
129+}
130+
131+func TestTokenCompletion(t *testing.T) {
132+ f := &fakeAPI{
133+ listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) {
134+ return &ermite.ListTokensResponse{Tokens: []*ermite.Token{{Id: "abc1", Label: "laptop"}, {Id: "xyz2", Label: "ci"}}}, nil
135+ },
136+ listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
137+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
138+ },
139+ }
140+ vars := envFor(f.serve(t))
141+ res := runCLI(t, vars, "", "__complete", "token", "revoke", "a").ok(t)
142+ assert.Contains(t, res.stdout, "abc1\tlaptop")
143+ assert.NotContains(t, res.stdout, "xyz2")
144+
145+ res = runCLI(t, vars, "", "__complete", "token", "create", "x", "--preset", "").ok(t)
146+ assert.Contains(t, res.stdout, "agent")
147+ res = runCLI(t, vars, "", "__complete", "token", "create", "x", "--scope", "repo:").ok(t)
148+ assert.Contains(t, res.stdout, "repo:read")
149+ res = runCLI(t, vars, "", "__complete", "token", "create", "x", "--repo", "").ok(t)
150+ assert.Contains(t, res.stdout, "alpha")
151+}
A cmd/ermite/cli_version.go +105 -0
@@ -0,0 +1,105 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "fmt"
7+ "strings"
8+
9+ "connectrpc.com/connect"
10+ "github.com/spf13/cobra"
11+
12+ "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/adapters/client"
14+)
15+
16+// jsonVersion is the stable JSON shape of `ermite version --json`.
17+type jsonVersion struct {
18+ Client jsonClientVersion `json:"client"`
19+ Server *jsonServerVersion `json:"server"`
20+}
21+
22+type jsonClientVersion struct {
23+ Version string `json:"version"`
24+}
25+
26+type jsonServerVersion struct {
27+ URL string `json:"url"`
28+ Version string `json:"version"`
29+ Features []string `json:"features"`
30+ MissingFeatures []string `json:"missing_features"`
31+}
32+
33+func newVersionCmd(env *cliEnv) *cobra.Command {
34+ cmd := &cobra.Command{
35+ Use: "version",
36+ Short: "Print the client version and, when ERMITE_URL is set, the server's",
37+ Long: "Print the version of this client and, when ERMITE_URL is set, the version of the server and\n" +
38+ "the optional features of the API it implements. A warning on standard error says which of the\n" +
39+ "features this client uses the server lacks. Without ERMITE_URL only the client is shown.\n\n" +
40+ "JSON fields (--json): client.version; server, null without ERMITE_URL, with url, version,\n" +
41+ "features (array of identifiers) and missing_features (the ones this client uses that the\n" +
42+ "server lacks).",
43+ Args: cobra.NoArgs,
44+ }
45+ asJSON := addJSONFlag(cmd)
46+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
47+ out := cmd.OutOrStdout()
48+ clientLine := "ermite " + env.version
49+ url := strings.TrimSpace(env.getenv(client.EnvURL))
50+ if url == "" {
51+ if *asJSON {
52+ return writeJSON(out, jsonVersion{Client: jsonClientVersion{Version: env.version}})
53+ }
54+ _, err := fmt.Fprintln(out, clientLine)
55+ return err
56+ }
57+
58+ c, err := env.client(cmd, true)
59+ if err != nil {
60+ return err
61+ }
62+ res, err := c.Server().GetVersion(env.ctx(cmd), connect.NewRequest(&ermite.GetVersionRequest{}))
63+ if err != nil {
64+ // Still show what is known locally before failing.
65+ if !*asJSON {
66+ _, _ = fmt.Fprintln(out, clientLine)
67+ }
68+ return c.Describe(err)
69+ }
70+ features := res.Msg.GetFeatures()
71+ if features == nil {
72+ features = []string{}
73+ }
74+ missing := client.MissingFeatures(features)
75+ if *asJSON {
76+ if missing == nil {
77+ missing = []string{}
78+ }
79+ err = writeJSON(out, jsonVersion{
80+ Client: jsonClientVersion{Version: env.version},
81+ Server: &jsonServerVersion{URL: c.URL(), Version: res.Msg.GetVersion(), Features: features, MissingFeatures: missing},
82+ })
83+ } else {
84+ err = fields(out, [][2]string{
85+ {"client", clientLine},
86+ {"server", res.Msg.GetVersion() + " at " + c.URL()},
87+ {"features", strings.Join(features, ", ")},
88+ })
89+ }
90+ if err != nil {
91+ return err
92+ }
93+ for _, f := range missing {
94+ warnf(cmd, "the server does not support %s, which %s", f, featureUse[f])
95+ }
96+ return nil
97+ }
98+ return cmd
99+}
100+
101+// featureUse says what breaks without a feature, for the version warning.
102+var featureUse = map[string]string{
103+ client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",
104+ client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
105+}
@@ -0,0 +1,105 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "fmt"
7+ "strings"
8+
9+ "connectrpc.com/connect"
10+ "github.com/spf13/cobra"
11+
12+ "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/adapters/client"
14+)
15+
16+// jsonVersion is the stable JSON shape of `ermite version --json`.
17+type jsonVersion struct {
18+ Client jsonClientVersion `json:"client"`
19+ Server *jsonServerVersion `json:"server"`
20+}
21+
22+type jsonClientVersion struct {
23+ Version string `json:"version"`
24+}
25+
26+type jsonServerVersion struct {
27+ URL string `json:"url"`
28+ Version string `json:"version"`
29+ Features []string `json:"features"`
30+ MissingFeatures []string `json:"missing_features"`
31+}
32+
33+func newVersionCmd(env *cliEnv) *cobra.Command {
34+ cmd := &cobra.Command{
35+ Use: "version",
36+ Short: "Print the client version and, when ERMITE_URL is set, the server's",
37+ Long: "Print the version of this client and, when ERMITE_URL is set, the version of the server and\n" +
38+ "the optional features of the API it implements. A warning on standard error says which of the\n" +
39+ "features this client uses the server lacks. Without ERMITE_URL only the client is shown.\n\n" +
40+ "JSON fields (--json): client.version; server, null without ERMITE_URL, with url, version,\n" +
41+ "features (array of identifiers) and missing_features (the ones this client uses that the\n" +
42+ "server lacks).",
43+ Args: cobra.NoArgs,
44+ }
45+ asJSON := addJSONFlag(cmd)
46+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
47+ out := cmd.OutOrStdout()
48+ clientLine := "ermite " + env.version
49+ url := strings.TrimSpace(env.getenv(client.EnvURL))
50+ if url == "" {
51+ if *asJSON {
52+ return writeJSON(out, jsonVersion{Client: jsonClientVersion{Version: env.version}})
53+ }
54+ _, err := fmt.Fprintln(out, clientLine)
55+ return err
56+ }
57+
58+ c, err := env.client(cmd, true)
59+ if err != nil {
60+ return err
61+ }
62+ res, err := c.Server().GetVersion(env.ctx(cmd), connect.NewRequest(&ermite.GetVersionRequest{}))
63+ if err != nil {
64+ // Still show what is known locally before failing.
65+ if !*asJSON {
66+ _, _ = fmt.Fprintln(out, clientLine)
67+ }
68+ return c.Describe(err)
69+ }
70+ features := res.Msg.GetFeatures()
71+ if features == nil {
72+ features = []string{}
73+ }
74+ missing := client.MissingFeatures(features)
75+ if *asJSON {
76+ if missing == nil {
77+ missing = []string{}
78+ }
79+ err = writeJSON(out, jsonVersion{
80+ Client: jsonClientVersion{Version: env.version},
81+ Server: &jsonServerVersion{URL: c.URL(), Version: res.Msg.GetVersion(), Features: features, MissingFeatures: missing},
82+ })
83+ } else {
84+ err = fields(out, [][2]string{
85+ {"client", clientLine},
86+ {"server", res.Msg.GetVersion() + " at " + c.URL()},
87+ {"features", strings.Join(features, ", ")},
88+ })
89+ }
90+ if err != nil {
91+ return err
92+ }
93+ for _, f := range missing {
94+ warnf(cmd, "the server does not support %s, which %s", f, featureUse[f])
95+ }
96+ return nil
97+ }
98+ return cmd
99+}
100+
101+// featureUse says what breaks without a feature, for the version warning.
102+var featureUse = map[string]string{
103+ client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",
104+ client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
105+}
A cmd/ermite/cli_version_test.go +74 -0
@@ -0,0 +1,74 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "errors"
7+ "testing"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite-api/gen/ermite"
14+)
15+
16+func versionFake(features ...string) *fakeAPI {
17+ return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
18+ return &ermite.GetVersionResponse{Version: "v0.4.0", Features: features}, nil
19+ }}
20+}
21+
22+func TestVersionClientOnlyWithoutURL(t *testing.T) {
23+ res := runCLI(t, nil, "", "version").ok(t)
24+ assert.Equal(t, "ermite v0.0.1\n", res.stdout)
25+ assert.Empty(t, res.stderr)
26+
27+ res = runCLI(t, nil, "", "version", "--json").ok(t)
28+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":null}`, res.stdout)
29+}
30+
31+func TestVersionWithServer(t *testing.T) {
32+ f := versionFake("repo.update", "events.resume")
33+ url := f.serve(t)
34+ res := runCLI(t, envFor(url), "", "version").ok(t)
35+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume\n", res.stdout)
36+ assert.Empty(t, res.stderr, "no warning when every feature is there")
37+
38+ res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
39+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume"],"missing_features":[]}}`, res.stdout)
40+}
41+
42+func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
43+ f := versionFake("repo.update")
44+ url := f.serve(t)
45+ res := runCLI(t, envFor(url), "", "version").ok(t)
46+ assert.Contains(t, res.stdout, "v0.4.0")
47+ assert.Contains(t, res.stderr, "ermite: warning: the server does not support events.resume")
48+ assert.NotContains(t, res.stderr, "repo.update")
49+
50+ res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
51+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update"],"missing_features":["events.resume"]}}`, res.stdout)
52+ assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
53+
54+ f = versionFake()
55+ res = runCLI(t, envFor(f.serve(t)), "", "version").ok(t)
56+ assert.Contains(t, res.stderr, "repo.update")
57+ assert.Contains(t, res.stderr, "events.resume")
58+}
59+
60+func TestVersionServerFailureStillShowsClient(t *testing.T) {
61+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
62+ return nil, connect.NewError(connect.CodeInternal, errors.New("boom"))
63+ }}
64+ res := runCLI(t, envFor(f.serve(t)), "", "version")
65+ require.Error(t, res.err)
66+ assert.Equal(t, "ermite v0.0.1\n", res.stdout)
67+ assert.Contains(t, res.err.Error(), "server error: boom")
68+}
69+
70+func TestVersionWorksWithoutToken(t *testing.T) {
71+ f := versionFake("repo.update", "events.resume")
72+ runCLI(t, map[string]string{"ERMITE_URL": f.serve(t)}, "", "version").ok(t)
73+ assert.Empty(t, f.header[0].Values("Authorization"))
74+}
@@ -0,0 +1,74 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "errors"
7+ "testing"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite-api/gen/ermite"
14+)
15+
16+func versionFake(features ...string) *fakeAPI {
17+ return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
18+ return &ermite.GetVersionResponse{Version: "v0.4.0", Features: features}, nil
19+ }}
20+}
21+
22+func TestVersionClientOnlyWithoutURL(t *testing.T) {
23+ res := runCLI(t, nil, "", "version").ok(t)
24+ assert.Equal(t, "ermite v0.0.1\n", res.stdout)
25+ assert.Empty(t, res.stderr)
26+
27+ res = runCLI(t, nil, "", "version", "--json").ok(t)
28+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":null}`, res.stdout)
29+}
30+
31+func TestVersionWithServer(t *testing.T) {
32+ f := versionFake("repo.update", "events.resume")
33+ url := f.serve(t)
34+ res := runCLI(t, envFor(url), "", "version").ok(t)
35+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume\n", res.stdout)
36+ assert.Empty(t, res.stderr, "no warning when every feature is there")
37+
38+ res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
39+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume"],"missing_features":[]}}`, res.stdout)
40+}
41+
42+func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
43+ f := versionFake("repo.update")
44+ url := f.serve(t)
45+ res := runCLI(t, envFor(url), "", "version").ok(t)
46+ assert.Contains(t, res.stdout, "v0.4.0")
47+ assert.Contains(t, res.stderr, "ermite: warning: the server does not support events.resume")
48+ assert.NotContains(t, res.stderr, "repo.update")
49+
50+ res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
51+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update"],"missing_features":["events.resume"]}}`, res.stdout)
52+ assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
53+
54+ f = versionFake()
55+ res = runCLI(t, envFor(f.serve(t)), "", "version").ok(t)
56+ assert.Contains(t, res.stderr, "repo.update")
57+ assert.Contains(t, res.stderr, "events.resume")
58+}
59+
60+func TestVersionServerFailureStillShowsClient(t *testing.T) {
61+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
62+ return nil, connect.NewError(connect.CodeInternal, errors.New("boom"))
63+ }}
64+ res := runCLI(t, envFor(f.serve(t)), "", "version")
65+ require.Error(t, res.err)
66+ assert.Equal(t, "ermite v0.0.1\n", res.stdout)
67+ assert.Contains(t, res.err.Error(), "server error: boom")
68+}
69+
70+func TestVersionWorksWithoutToken(t *testing.T) {
71+ f := versionFake("repo.update", "events.resume")
72+ runCLI(t, map[string]string{"ERMITE_URL": f.serve(t)}, "", "version").ok(t)
73+ assert.Empty(t, f.header[0].Values("Authorization"))
74+}
A cmd/ermite/cli_watch.go +162 -0
@@ -0,0 +1,162 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "fmt"
7+ "os"
8+ "os/signal"
9+ "strings"
10+ "syscall"
11+
12+ "github.com/spf13/cobra"
13+
14+ "bnema.dev/ermite-api/gen/ermite"
15+ "bnema.dev/ermite/internal/adapters/client"
16+)
17+
18+// jsonEvent is the stable JSON shape of one event: one object per line with
19+// `ermite watch --json`. Fields not meaningful for a type are omitted.
20+type jsonEvent struct {
21+ ID string `json:"id"`
22+ Time *string `json:"time"`
23+ Type string `json:"type"`
24+ Repo string `json:"repo,omitempty"`
25+ Repository *jsonRepo `json:"repository,omitempty"`
26+ RefUpdates []jsonRefUpdate `json:"ref_updates,omitempty"`
27+}
28+
29+type jsonRefUpdate struct {
30+ Ref string `json:"ref"`
31+ OldID string `json:"old_id"`
32+ NewID string `json:"new_id"`
33+}
34+
35+// Event types in the output.
36+const (
37+ eventRepoCreated = "repo_created"
38+ eventRepoUpdated = "repo_updated"
39+ eventRepoDeleted = "repo_deleted"
40+ eventPushed = "pushed"
41+ eventGap = "gap"
42+)
43+
44+// toJSONEvent maps an event; ok is false for kinds this client does not know,
45+// which the contract says to ignore.
46+func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) {
47+ out := jsonEvent{ID: ev.GetId(), Time: jsonTime(ev.GetTime())}
48+ switch k := ev.GetKind().(type) {
49+ case *ermite.Event_RepoCreated:
50+ r := toJSONRepo(k.RepoCreated.GetRepo())
51+ out.Type, out.Repo, out.Repository = eventRepoCreated, r.Name, &r
52+ case *ermite.Event_RepoUpdated:
53+ r := toJSONRepo(k.RepoUpdated.GetRepo())
54+ out.Type, out.Repo, out.Repository = eventRepoUpdated, r.Name, &r
55+ case *ermite.Event_RepoDeleted:
56+ out.Type, out.Repo = eventRepoDeleted, k.RepoDeleted.GetRepo()
57+ case *ermite.Event_Pushed:
58+ out.Type, out.Repo = eventPushed, k.Pushed.GetRepo()
59+ out.RefUpdates = []jsonRefUpdate{}
60+ for _, u := range k.Pushed.GetRefUpdates() {
61+ out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()})
62+ }
63+ case *ermite.Event_Gap:
64+ out.Type = eventGap
65+ default:
66+ return jsonEvent{}, false
67+ }
68+ return out, true
69+}
70+
71+// eventLine is the human line of an event: "<time> <type> <details>".
72+func eventLine(e jsonEvent) string {
73+ ts := "-"
74+ if e.Time != nil {
75+ ts = *e.Time
76+ }
77+ var detail string
78+ switch e.Type {
79+ case eventRepoCreated:
80+ detail = e.Repo + " (" + e.Repository.Visibility + ")"
81+ case eventRepoUpdated:
82+ detail = e.Repo + " (" + e.Repository.Visibility + ", default branch " + e.Repository.DefaultBranch + ")"
83+ case eventRepoDeleted:
84+ detail = e.Repo
85+ case eventPushed:
86+ parts := make([]string, 0, len(e.RefUpdates))
87+ for _, u := range e.RefUpdates {
88+ parts = append(parts, refUpdateText(u))
89+ }
90+ detail = e.Repo + " " + strings.Join(parts, ", ")
91+ case eventGap:
92+ detail = "events were lost, reload what you display"
93+ }
94+ return clean(ts + " " + e.Type + " " + detail)
95+}
96+
97+func refUpdateText(u jsonRefUpdate) string {
98+ short := func(id string) string {
99+ if len(id) > 8 {
100+ return id[:8]
101+ }
102+ return id
103+ }
104+ switch {
105+ case u.OldID == "":
106+ return u.Ref + " created at " + short(u.NewID)
107+ case u.NewID == "":
108+ return u.Ref + " deleted (was " + short(u.OldID) + ")"
109+ default:
110+ return u.Ref + " " + short(u.OldID) + ".." + short(u.NewID)
111+ }
112+}
113+
114+func newWatchCmd(env *cliEnv) *cobra.Command {
115+ cmd := &cobra.Command{
116+ Use: "watch [repo]",
117+ Short: "Print repository events as they happen",
118+ Long: "Print one line per event (repository created, updated or deleted, refs pushed) for every\n" +
119+ "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" +
120+ "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" +
121+ "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" +
122+ "error.\n\n" +
123+ "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" +
124+ "(repo_created, repo_updated, repo_deleted, pushed or gap) and, depending on the type, repo (name),\n" +
125+ "repository (the repository fields of `repo show --json`) and ref_updates (array of ref, old_id,\n" +
126+ "new_id; an empty id means the ref was created or deleted). Unknown event types are skipped.",
127+ Args: cobra.MaximumNArgs(1),
128+ ValidArgsFunction: completeRepoNames(env),
129+ }
130+ asJSON := addJSONFlag(cmd)
131+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
132+ topics := []string{"home"}
133+ if len(args) == 1 {
134+ topics = []string{"repo:" + args[0]}
135+ }
136+ c, err := env.client(cmd, true)
137+ if err != nil {
138+ return err
139+ }
140+ ctx, stop := signal.NotifyContext(env.ctx(cmd), os.Interrupt, syscall.SIGTERM)
141+ defer stop()
142+ out := cmd.OutOrStdout()
143+ opts := client.WatchOptions{
144+ Topics: topics,
145+ Backoff: env.backoff,
146+ Sleep: env.sleep,
147+ OnNotice: func(msg string) { noticef(cmd, "%s", msg) },
148+ }
149+ return c.Watch(ctx, opts, func(ev *ermite.Event) error {
150+ e, ok := toJSONEvent(ev)
151+ if !ok {
152+ return nil
153+ }
154+ if *asJSON {
155+ return writeNDJSON(out, e)
156+ }
157+ _, err := fmt.Fprintln(out, eventLine(e))
158+ return err
159+ })
160+ }
161+ return cmd
162+}
@@ -0,0 +1,162 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "fmt"
7+ "os"
8+ "os/signal"
9+ "strings"
10+ "syscall"
11+
12+ "github.com/spf13/cobra"
13+
14+ "bnema.dev/ermite-api/gen/ermite"
15+ "bnema.dev/ermite/internal/adapters/client"
16+)
17+
18+// jsonEvent is the stable JSON shape of one event: one object per line with
19+// `ermite watch --json`. Fields not meaningful for a type are omitted.
20+type jsonEvent struct {
21+ ID string `json:"id"`
22+ Time *string `json:"time"`
23+ Type string `json:"type"`
24+ Repo string `json:"repo,omitempty"`
25+ Repository *jsonRepo `json:"repository,omitempty"`
26+ RefUpdates []jsonRefUpdate `json:"ref_updates,omitempty"`
27+}
28+
29+type jsonRefUpdate struct {
30+ Ref string `json:"ref"`
31+ OldID string `json:"old_id"`
32+ NewID string `json:"new_id"`
33+}
34+
35+// Event types in the output.
36+const (
37+ eventRepoCreated = "repo_created"
38+ eventRepoUpdated = "repo_updated"
39+ eventRepoDeleted = "repo_deleted"
40+ eventPushed = "pushed"
41+ eventGap = "gap"
42+)
43+
44+// toJSONEvent maps an event; ok is false for kinds this client does not know,
45+// which the contract says to ignore.
46+func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) {
47+ out := jsonEvent{ID: ev.GetId(), Time: jsonTime(ev.GetTime())}
48+ switch k := ev.GetKind().(type) {
49+ case *ermite.Event_RepoCreated:
50+ r := toJSONRepo(k.RepoCreated.GetRepo())
51+ out.Type, out.Repo, out.Repository = eventRepoCreated, r.Name, &r
52+ case *ermite.Event_RepoUpdated:
53+ r := toJSONRepo(k.RepoUpdated.GetRepo())
54+ out.Type, out.Repo, out.Repository = eventRepoUpdated, r.Name, &r
55+ case *ermite.Event_RepoDeleted:
56+ out.Type, out.Repo = eventRepoDeleted, k.RepoDeleted.GetRepo()
57+ case *ermite.Event_Pushed:
58+ out.Type, out.Repo = eventPushed, k.Pushed.GetRepo()
59+ out.RefUpdates = []jsonRefUpdate{}
60+ for _, u := range k.Pushed.GetRefUpdates() {
61+ out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()})
62+ }
63+ case *ermite.Event_Gap:
64+ out.Type = eventGap
65+ default:
66+ return jsonEvent{}, false
67+ }
68+ return out, true
69+}
70+
71+// eventLine is the human line of an event: "<time> <type> <details>".
72+func eventLine(e jsonEvent) string {
73+ ts := "-"
74+ if e.Time != nil {
75+ ts = *e.Time
76+ }
77+ var detail string
78+ switch e.Type {
79+ case eventRepoCreated:
80+ detail = e.Repo + " (" + e.Repository.Visibility + ")"
81+ case eventRepoUpdated:
82+ detail = e.Repo + " (" + e.Repository.Visibility + ", default branch " + e.Repository.DefaultBranch + ")"
83+ case eventRepoDeleted:
84+ detail = e.Repo
85+ case eventPushed:
86+ parts := make([]string, 0, len(e.RefUpdates))
87+ for _, u := range e.RefUpdates {
88+ parts = append(parts, refUpdateText(u))
89+ }
90+ detail = e.Repo + " " + strings.Join(parts, ", ")
91+ case eventGap:
92+ detail = "events were lost, reload what you display"
93+ }
94+ return clean(ts + " " + e.Type + " " + detail)
95+}
96+
97+func refUpdateText(u jsonRefUpdate) string {
98+ short := func(id string) string {
99+ if len(id) > 8 {
100+ return id[:8]
101+ }
102+ return id
103+ }
104+ switch {
105+ case u.OldID == "":
106+ return u.Ref + " created at " + short(u.NewID)
107+ case u.NewID == "":
108+ return u.Ref + " deleted (was " + short(u.OldID) + ")"
109+ default:
110+ return u.Ref + " " + short(u.OldID) + ".." + short(u.NewID)
111+ }
112+}
113+
114+func newWatchCmd(env *cliEnv) *cobra.Command {
115+ cmd := &cobra.Command{
116+ Use: "watch [repo]",
117+ Short: "Print repository events as they happen",
118+ Long: "Print one line per event (repository created, updated or deleted, refs pushed) for every\n" +
119+ "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" +
120+ "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" +
121+ "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" +
122+ "error.\n\n" +
123+ "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" +
124+ "(repo_created, repo_updated, repo_deleted, pushed or gap) and, depending on the type, repo (name),\n" +
125+ "repository (the repository fields of `repo show --json`) and ref_updates (array of ref, old_id,\n" +
126+ "new_id; an empty id means the ref was created or deleted). Unknown event types are skipped.",
127+ Args: cobra.MaximumNArgs(1),
128+ ValidArgsFunction: completeRepoNames(env),
129+ }
130+ asJSON := addJSONFlag(cmd)
131+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
132+ topics := []string{"home"}
133+ if len(args) == 1 {
134+ topics = []string{"repo:" + args[0]}
135+ }
136+ c, err := env.client(cmd, true)
137+ if err != nil {
138+ return err
139+ }
140+ ctx, stop := signal.NotifyContext(env.ctx(cmd), os.Interrupt, syscall.SIGTERM)
141+ defer stop()
142+ out := cmd.OutOrStdout()
143+ opts := client.WatchOptions{
144+ Topics: topics,
145+ Backoff: env.backoff,
146+ Sleep: env.sleep,
147+ OnNotice: func(msg string) { noticef(cmd, "%s", msg) },
148+ }
149+ return c.Watch(ctx, opts, func(ev *ermite.Event) error {
150+ e, ok := toJSONEvent(ev)
151+ if !ok {
152+ return nil
153+ }
154+ if *asJSON {
155+ return writeNDJSON(out, e)
156+ }
157+ _, err := fmt.Fprintln(out, eventLine(e))
158+ return err
159+ })
160+ }
161+ return cmd
162+}
A cmd/ermite/cli_watch_test.go +149 -0
@@ -0,0 +1,149 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "bufio"
7+ "context"
8+ "encoding/json"
9+ "errors"
10+ "strings"
11+ "testing"
12+
13+ "connectrpc.com/connect"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite-api/gen/ermite"
18+)
19+
20+func evt(id string, kind func(*ermite.Event)) *ermite.WatchResponse {
21+ e := &ermite.Event{Id: id, Time: ts("2026-10-10T12:00:00Z")}
22+ kind(e)
23+ return &ermite.WatchResponse{Event: e}
24+}
25+
26+// sampleEvents is one of each kind, plus a kind this client does not know.
27+func sampleEvents() []*ermite.WatchResponse {
28+ return []*ermite.WatchResponse{
29+ evt("1", func(e *ermite.Event) {
30+ e.Kind = &ermite.Event_RepoCreated{RepoCreated: &ermite.RepoCreated{Repo: repoBeta}}
31+ }),
32+ evt("2", func(e *ermite.Event) {
33+ e.Kind = &ermite.Event_RepoUpdated{RepoUpdated: &ermite.RepoUpdated{Repo: repoAlpha}}
34+ }),
35+ evt("3", func(e *ermite.Event) {
36+ e.Kind = &ermite.Event_Pushed{Pushed: &ermite.Pushed{Repo: "alpha", RefUpdates: []*ermite.RefUpdate{
37+ {Ref: "refs/heads/main", OldId: "1111111111111111", NewId: "2222222222222222"},
38+ {Ref: "refs/heads/new", NewId: "3333333333333333"},
39+ {Ref: "refs/tags/old", OldId: "4444444444444444"},
40+ }}}
41+ }),
42+ evt("4", func(e *ermite.Event) {
43+ e.Kind = &ermite.Event_RepoDeleted{RepoDeleted: &ermite.RepoDeleted{Repo: "beta"}}
44+ }),
45+ {Event: &ermite.Event{Kind: &ermite.Event_Gap{Gap: &ermite.Gap{}}}},
46+ {Event: &ermite.Event{Id: "future"}}, // unknown kind: skipped
47+ }
48+}
49+
50+// finishing streams events, then answers permission_denied to end the command.
51+func finishing(events []*ermite.WatchResponse) func(context.Context, *ermite.WatchRequest, *connect.ServerStream[ermite.WatchResponse]) error {
52+ return func(_ context.Context, _ *ermite.WatchRequest, st *connect.ServerStream[ermite.WatchResponse]) error {
53+ for _, e := range events {
54+ if err := st.Send(e); err != nil {
55+ return err
56+ }
57+ }
58+ return connect.NewError(connect.CodePermissionDenied, errors.New("done"))
59+ }
60+}
61+
62+func TestWatchHumanLines(t *testing.T) {
63+ f := &fakeAPI{watch: finishing(sampleEvents())}
64+ res := runCLI(t, envFor(f.serve(t)), "", "watch")
65+ require.Error(t, res.err)
66+ assert.Equal(t, ""+
67+ "2026-10-10T12:00:00Z repo_created beta (private)\n"+
68+ "2026-10-10T12:00:00Z repo_updated alpha (public, default branch main)\n"+
69+ "2026-10-10T12:00:00Z pushed alpha refs/heads/main 11111111..22222222, refs/heads/new created at 33333333, refs/tags/old deleted (was 44444444)\n"+
70+ "2026-10-10T12:00:00Z repo_deleted beta\n"+
71+ "- gap events were lost, reload what you display\n", res.stdout)
72+}
73+
74+func TestWatchTopics(t *testing.T) {
75+ f := &fakeAPI{watch: finishing(nil)}
76+ url := f.serve(t)
77+ _ = runCLI(t, envFor(url), "", "watch")
78+ _ = runCLI(t, envFor(url), "", "watch", "alpha")
79+ _, reqs := f.snapshot()
80+ require.Len(t, reqs, 2)
81+ assert.Equal(t, []string{"home"}, reqs[0].(*ermite.WatchRequest).GetTopics())
82+ assert.Equal(t, []string{"repo:alpha"}, reqs[1].(*ermite.WatchRequest).GetTopics())
83+ assert.Empty(t, reqs[0].(*ermite.WatchRequest).GetResumeAfter())
84+}
85+
86+func TestWatchNDJSON(t *testing.T) {
87+ f := &fakeAPI{watch: finishing(sampleEvents())}
88+ res := runCLI(t, envFor(f.serve(t)), "", "watch", "--json")
89+ require.Error(t, res.err)
90+
91+ var lines []string
92+ sc := bufio.NewScanner(strings.NewReader(res.stdout))
93+ for sc.Scan() {
94+ lines = append(lines, sc.Text())
95+ assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())
96+ }
97+ require.Len(t, lines, 5)
98+ assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null}}`, lines[0])
99+ assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[
100+ {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},
101+ {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},
102+ {"ref":"refs/tags/old","old_id":"4444444444444444","new_id":""}]}`, lines[2])
103+ assert.JSONEq(t, `{"id":"4","time":"2026-10-10T12:00:00Z","type":"repo_deleted","repo":"beta"}`, lines[3])
104+ assert.JSONEq(t, `{"id":"","time":null,"type":"gap"}`, lines[4])
105+}
106+
107+func TestWatchReconnectsAndResumes(t *testing.T) {
108+ calls := 0
109+ f := &fakeAPI{}
110+ f.watch = func(_ context.Context, req *ermite.WatchRequest, st *connect.ServerStream[ermite.WatchResponse]) error {
111+ calls++
112+ switch calls {
113+ case 1:
114+ _ = st.Send(sampleEvents()[0])
115+ return connect.NewError(connect.CodeUnavailable, errors.New("restarting"))
116+ case 2:
117+ assert.Equal(t, "1", req.GetResumeAfter(), "resumes after the last printed event")
118+ _ = st.Send(&ermite.WatchResponse{Event: &ermite.Event{Kind: &ermite.Event_Gap{Gap: &ermite.Gap{}}}})
119+ _ = st.Send(sampleEvents()[3])
120+ return connect.NewError(connect.CodePermissionDenied, errors.New("done"))
121+ }
122+ return connect.NewError(connect.CodeInternal, nil)
123+ }
124+ res := runCLI(t, envFor(f.serve(t)), "", "watch")
125+ require.Error(t, res.err)
126+ assert.Equal(t, 2, calls)
127+ assert.Contains(t, res.stderr, "ermite: connection lost")
128+ assert.Contains(t, res.stderr, "restarting")
129+ assert.Contains(t, res.stderr, "ermite: reconnected")
130+ lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
131+ require.Len(t, lines, 3)
132+ assert.Contains(t, lines[0], "repo_created")
133+ assert.Contains(t, lines[1], "gap")
134+ assert.Contains(t, lines[2], "repo_deleted")
135+}
136+
137+func TestWatchPermanentErrorIsFriendly(t *testing.T) {
138+ f := &fakeAPI{watch: func(context.Context, *ermite.WatchRequest, *connect.ServerStream[ermite.WatchResponse]) error {
139+ return connect.NewError(connect.CodeNotFound, errors.New("repository \"zzz\" not found"))
140+ }}
141+ res := runCLI(t, envFor(f.serve(t)), "", "watch", "zzz")
142+ require.Error(t, res.err)
143+ assert.Equal(t, "not found: repository \"zzz\" not found", res.err.Error())
144+}
145+
146+func TestWatchRejectsTooManyArgs(t *testing.T) {
147+ res := runCLI(t, envFor("http://127.0.0.1:1"), "", "watch", "a", "b")
148+ require.Error(t, res.err)
149+}
@@ -0,0 +1,149 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "bufio"
7+ "context"
8+ "encoding/json"
9+ "errors"
10+ "strings"
11+ "testing"
12+
13+ "connectrpc.com/connect"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite-api/gen/ermite"
18+)
19+
20+func evt(id string, kind func(*ermite.Event)) *ermite.WatchResponse {
21+ e := &ermite.Event{Id: id, Time: ts("2026-10-10T12:00:00Z")}
22+ kind(e)
23+ return &ermite.WatchResponse{Event: e}
24+}
25+
26+// sampleEvents is one of each kind, plus a kind this client does not know.
27+func sampleEvents() []*ermite.WatchResponse {
28+ return []*ermite.WatchResponse{
29+ evt("1", func(e *ermite.Event) {
30+ e.Kind = &ermite.Event_RepoCreated{RepoCreated: &ermite.RepoCreated{Repo: repoBeta}}
31+ }),
32+ evt("2", func(e *ermite.Event) {
33+ e.Kind = &ermite.Event_RepoUpdated{RepoUpdated: &ermite.RepoUpdated{Repo: repoAlpha}}
34+ }),
35+ evt("3", func(e *ermite.Event) {
36+ e.Kind = &ermite.Event_Pushed{Pushed: &ermite.Pushed{Repo: "alpha", RefUpdates: []*ermite.RefUpdate{
37+ {Ref: "refs/heads/main", OldId: "1111111111111111", NewId: "2222222222222222"},
38+ {Ref: "refs/heads/new", NewId: "3333333333333333"},
39+ {Ref: "refs/tags/old", OldId: "4444444444444444"},
40+ }}}
41+ }),
42+ evt("4", func(e *ermite.Event) {
43+ e.Kind = &ermite.Event_RepoDeleted{RepoDeleted: &ermite.RepoDeleted{Repo: "beta"}}
44+ }),
45+ {Event: &ermite.Event{Kind: &ermite.Event_Gap{Gap: &ermite.Gap{}}}},
46+ {Event: &ermite.Event{Id: "future"}}, // unknown kind: skipped
47+ }
48+}
49+
50+// finishing streams events, then answers permission_denied to end the command.
51+func finishing(events []*ermite.WatchResponse) func(context.Context, *ermite.WatchRequest, *connect.ServerStream[ermite.WatchResponse]) error {
52+ return func(_ context.Context, _ *ermite.WatchRequest, st *connect.ServerStream[ermite.WatchResponse]) error {
53+ for _, e := range events {
54+ if err := st.Send(e); err != nil {
55+ return err
56+ }
57+ }
58+ return connect.NewError(connect.CodePermissionDenied, errors.New("done"))
59+ }
60+}
61+
62+func TestWatchHumanLines(t *testing.T) {
63+ f := &fakeAPI{watch: finishing(sampleEvents())}
64+ res := runCLI(t, envFor(f.serve(t)), "", "watch")
65+ require.Error(t, res.err)
66+ assert.Equal(t, ""+
67+ "2026-10-10T12:00:00Z repo_created beta (private)\n"+
68+ "2026-10-10T12:00:00Z repo_updated alpha (public, default branch main)\n"+
69+ "2026-10-10T12:00:00Z pushed alpha refs/heads/main 11111111..22222222, refs/heads/new created at 33333333, refs/tags/old deleted (was 44444444)\n"+
70+ "2026-10-10T12:00:00Z repo_deleted beta\n"+
71+ "- gap events were lost, reload what you display\n", res.stdout)
72+}
73+
74+func TestWatchTopics(t *testing.T) {
75+ f := &fakeAPI{watch: finishing(nil)}
76+ url := f.serve(t)
77+ _ = runCLI(t, envFor(url), "", "watch")
78+ _ = runCLI(t, envFor(url), "", "watch", "alpha")
79+ _, reqs := f.snapshot()
80+ require.Len(t, reqs, 2)
81+ assert.Equal(t, []string{"home"}, reqs[0].(*ermite.WatchRequest).GetTopics())
82+ assert.Equal(t, []string{"repo:alpha"}, reqs[1].(*ermite.WatchRequest).GetTopics())
83+ assert.Empty(t, reqs[0].(*ermite.WatchRequest).GetResumeAfter())
84+}
85+
86+func TestWatchNDJSON(t *testing.T) {
87+ f := &fakeAPI{watch: finishing(sampleEvents())}
88+ res := runCLI(t, envFor(f.serve(t)), "", "watch", "--json")
89+ require.Error(t, res.err)
90+
91+ var lines []string
92+ sc := bufio.NewScanner(strings.NewReader(res.stdout))
93+ for sc.Scan() {
94+ lines = append(lines, sc.Text())
95+ assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())
96+ }
97+ require.Len(t, lines, 5)
98+ assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null}}`, lines[0])
99+ assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[
100+ {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},
101+ {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},
102+ {"ref":"refs/tags/old","old_id":"4444444444444444","new_id":""}]}`, lines[2])
103+ assert.JSONEq(t, `{"id":"4","time":"2026-10-10T12:00:00Z","type":"repo_deleted","repo":"beta"}`, lines[3])
104+ assert.JSONEq(t, `{"id":"","time":null,"type":"gap"}`, lines[4])
105+}
106+
107+func TestWatchReconnectsAndResumes(t *testing.T) {
108+ calls := 0
109+ f := &fakeAPI{}
110+ f.watch = func(_ context.Context, req *ermite.WatchRequest, st *connect.ServerStream[ermite.WatchResponse]) error {
111+ calls++
112+ switch calls {
113+ case 1:
114+ _ = st.Send(sampleEvents()[0])
115+ return connect.NewError(connect.CodeUnavailable, errors.New("restarting"))
116+ case 2:
117+ assert.Equal(t, "1", req.GetResumeAfter(), "resumes after the last printed event")
118+ _ = st.Send(&ermite.WatchResponse{Event: &ermite.Event{Kind: &ermite.Event_Gap{Gap: &ermite.Gap{}}}})
119+ _ = st.Send(sampleEvents()[3])
120+ return connect.NewError(connect.CodePermissionDenied, errors.New("done"))
121+ }
122+ return connect.NewError(connect.CodeInternal, nil)
123+ }
124+ res := runCLI(t, envFor(f.serve(t)), "", "watch")
125+ require.Error(t, res.err)
126+ assert.Equal(t, 2, calls)
127+ assert.Contains(t, res.stderr, "ermite: connection lost")
128+ assert.Contains(t, res.stderr, "restarting")
129+ assert.Contains(t, res.stderr, "ermite: reconnected")
130+ lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
131+ require.Len(t, lines, 3)
132+ assert.Contains(t, lines[0], "repo_created")
133+ assert.Contains(t, lines[1], "gap")
134+ assert.Contains(t, lines[2], "repo_deleted")
135+}
136+
137+func TestWatchPermanentErrorIsFriendly(t *testing.T) {
138+ f := &fakeAPI{watch: func(context.Context, *ermite.WatchRequest, *connect.ServerStream[ermite.WatchResponse]) error {
139+ return connect.NewError(connect.CodeNotFound, errors.New("repository \"zzz\" not found"))
140+ }}
141+ res := runCLI(t, envFor(f.serve(t)), "", "watch", "zzz")
142+ require.Error(t, res.err)
143+ assert.Equal(t, "not found: repository \"zzz\" not found", res.err.Error())
144+}
145+
146+func TestWatchRejectsTooManyArgs(t *testing.T) {
147+ res := runCLI(t, envFor("http://127.0.0.1:1"), "", "watch", "a", "b")
148+ require.Error(t, res.err)
149+}
M cmd/ermite/main.go +6 -3
@@ -16,10 +16,13 @@ func main() {
1616 }
1717 }
1818
19-func version() string {
19+// buildVersion is the module version stamped into the binary.
20+func buildVersion() string {
2021 info, ok := debug.ReadBuildInfo()
2122 if !ok || info.Main.Version == "" {
22- return "ermite (devel)"
23+ return "(devel)"
2324 }
24- return "ermite " + info.Main.Version
25+ return info.Main.Version
2526 }
27+
28+func version() string { return "ermite " + buildVersion() }
@@ -16,10 +16,13 @@ func main() {
16 }16 }
17 }17 }
18 18
19-func version() string {19+// buildVersion is the module version stamped into the binary.
20+func buildVersion() string {
20 info, ok := debug.ReadBuildInfo()21 info, ok := debug.ReadBuildInfo()
21 if !ok || info.Main.Version == "" {22 if !ok || info.Main.Version == "" {
22- return "ermite (devel)"23+ return "(devel)"
23 }24 }
24- return "ermite " + info.Main.Version25+ return info.Main.Version
25 }26 }
27+
28+func version() string { return "ermite " + buildVersion() }
M cmd/ermite/root.go +16 -20
@@ -3,33 +3,29 @@
33 package main
44
55 import (
6- "fmt"
7-
86 "github.com/spf13/cobra"
97 )
108
119 func newRootCmd() *cobra.Command {
10+ return newRootCmdWith(defaultCLIEnv())
11+}
12+
13+// newRootCmdWith builds the command tree around env, which the remote commands
14+// use to reach the environment, the clock and the build version.
15+func newRootCmdWith(env *cliEnv) *cobra.Command {
1216 root := &cobra.Command{
13- Use: "ermite",
14- Short: "A single-user Git forge",
17+ Use: "ermite",
18+ Short: "A single-user Git forge",
19+ Long: "ermite is a single-user Git forge: `ermite serve` runs the server, the other commands\n" +
20+ "drive a running server over its API.\n\n" +
21+ "Remote commands read ERMITE_URL (the server address, http:// or https://) and\n" +
22+ "ERMITE_TOKEN (a bearer token, see `ermite token create`) from the environment.",
1523 SilenceUsage: true,
1624 SilenceErrors: true, // main prints the error once
17- CompletionOptions: cobra.CompletionOptions{
18- DisableDefaultCmd: true,
19- },
2025 }
21- root.AddCommand(newServeCmd(), newVersionCmd(), newHookCmd())
26+ root.AddCommand(
27+ newServeCmd(), newHookCmd(),
28+ newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
29+ )
2230 return root
2331 }
24-
25-func newVersionCmd() *cobra.Command {
26- return &cobra.Command{
27- Use: "version",
28- Short: "Print the version",
29- Args: cobra.NoArgs,
30- RunE: func(cmd *cobra.Command, _ []string) error {
31- _, err := fmt.Fprintln(cmd.OutOrStdout(), version())
32- return err
33- },
34- }
35-}
@@ -3,33 +3,29 @@
3 package main3 package main
4 4
5 import (5 import (
6- "fmt"
7-
8 "github.com/spf13/cobra"6 "github.com/spf13/cobra"
9 )7 )
10 8
11 func newRootCmd() *cobra.Command {9 func newRootCmd() *cobra.Command {
10+ return newRootCmdWith(defaultCLIEnv())
11+}
12+
13+// newRootCmdWith builds the command tree around env, which the remote commands
14+// use to reach the environment, the clock and the build version.
15+func newRootCmdWith(env *cliEnv) *cobra.Command {
12 root := &cobra.Command{16 root := &cobra.Command{
13- Use: "ermite",17+ Use: "ermite",
14- Short: "A single-user Git forge",18+ Short: "A single-user Git forge",
19+ Long: "ermite is a single-user Git forge: `ermite serve` runs the server, the other commands\n" +
20+ "drive a running server over its API.\n\n" +
21+ "Remote commands read ERMITE_URL (the server address, http:// or https://) and\n" +
22+ "ERMITE_TOKEN (a bearer token, see `ermite token create`) from the environment.",
15 SilenceUsage: true,23 SilenceUsage: true,
16 SilenceErrors: true, // main prints the error once24 SilenceErrors: true, // main prints the error once
17- CompletionOptions: cobra.CompletionOptions{
18- DisableDefaultCmd: true,
19- },
20 }25 }
21- root.AddCommand(newServeCmd(), newVersionCmd(), newHookCmd())26+ root.AddCommand(
27+ newServeCmd(), newHookCmd(),
28+ newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
29+ )
22 return root30 return root
23 }31 }
24-
25-func newVersionCmd() *cobra.Command {
26- return &cobra.Command{
27- Use: "version",
28- Short: "Print the version",
29- Args: cobra.NoArgs,
30- RunE: func(cmd *cobra.Command, _ []string) error {
31- _, err := fmt.Fprintln(cmd.OutOrStdout(), version())
32- return err
33- },
34- }
35-}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close