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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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() { | |||
| 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 | 21 | info, ok := debug.ReadBuildInfo() | |
| 21 | 22 | if !ok || info.Main.Version == "" { | |
| 22 | - | return "ermite (devel)" | |
| 23 | + | return "(devel)" | |
| 23 | 24 | } | |
| 24 | - | return "ermite " + info.Main.Version | |
| 25 | + | return info.Main.Version | |
| 25 | 26 | } | |
| 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.Version | 25 | + | 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 @@ | |||
| 3 | 3 | package main | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | - | "fmt" | |
| 7 | - | ||
| 8 | 6 | "github.com/spf13/cobra" | |
| 9 | 7 | ) | |
| 10 | 8 | ||
| 11 | 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 | 16 | 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.", | |
| 15 | 23 | SilenceUsage: true, | |
| 16 | 24 | 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 | 30 | 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 | - | } | |
| @@ -3,33 +3,29 @@ | |||||
| 3 | package main | 3 | 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 once | 24 | 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 root | 30 | 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 | - | } | |||