v0.12.1 7328df5

cli.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"context"
7	"encoding/json"
8	"fmt"
9	"io"
10	"os"
11	"strings"
12	"sync"
13	"text/tabwriter"
14	"time"
15
16	"github.com/bnema/zerowrap"
17	"github.com/spf13/cobra"
18	"google.golang.org/protobuf/types/known/timestamppb"
19
20	"bnema.dev/ermite/internal/adapters/client"
21	"bnema.dev/ermite/internal/config"
22)
23
24// cliEnv is what the remote commands read from the outside: the environment,
25// the clock and the build version. Tests replace it.
26type cliEnv struct {
27	getenv  func(string) string
28	now     func() time.Time
29	version string // bare build version, for example "v0.1.0" or "(devel)"
30	// backoff and sleep tune the reconnections of watch; zero values mean the
31	// defaults.
32	backoff client.Backoff
33	sleep   func(context.Context, time.Duration) error
34
35	// configPath returns the CLI config file path; nil means the default
36	// (ERMITE_CONFIG, else ~/.config/ermite/config).
37	configPath func() (string, error)
38	// runTokenCmd runs the token.cmd command line and returns its raw standard
39	// output. Tests replace it.
40	runTokenCmd func(ctx context.Context, cmd string) (string, error)
41
42	// The config file is read at most once and token.cmd run at most once per
43	// process, and only when a command needs them (see cli_configfile.go).
44	fileOnce  sync.Once
45	file      *cliFile
46	filePath  string
47	fileTrust error // why token.cmd must not run from this file, if so
48	fileErr   error
49	tokenOnce sync.Once
50	token     string
51	tokenErr  error
52}
53
54func defaultCLIEnv() *cliEnv {
55	return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion(), runTokenCmd: runTokenShell}
56}
57
58// userAgent identifies this CLI to the server.
59func (e *cliEnv) userAgent() string {
60	return "ermite/" + strings.Trim(e.version, "()") + " (cli)"
61}
62
63// ctx is the command context carrying a logger when ERMITE_LOG_LEVEL is set:
64// the remote commands log nothing by default (their output is the product).
65func (e *cliEnv) ctx(cmd *cobra.Command) context.Context {
66	ctx := cmd.Context()
67	if ctx == nil {
68		ctx = context.Background()
69	}
70	if lvl := e.getenv(config.EnvLogLevel); lvl != "" {
71		ctx = zerowrap.WithCtx(ctx, zerowrap.New(zerowrap.Config{Level: lvl, Format: "console", Output: cmd.ErrOrStderr()}))
72	}
73	return ctx
74}
75
76// buildConfig builds the client configuration: ERMITE_URL and ERMITE_TOKEN
77// win, then the config file (url, and the output of token.cmd for the token).
78// runTokenCmd false leaves token.cmd alone (ERMITE_TOKEN is still used), for
79// the calls that need no authentication.
80func (e *cliEnv) buildConfig(cmd *cobra.Command, warn, runTokenCmd bool) (client.Config, error) {
81	rawURL, err := e.resolveURL(cmd, warn)
82	if err != nil {
83		return client.Config{}, err
84	}
85	if strings.TrimSpace(rawURL) != "" {
86		if _, err := client.ParseURL(strings.TrimSpace(rawURL)); err != nil {
87			return client.Config{}, fmt.Errorf("%s: %w", e.urlSource(), err)
88		}
89	}
90	// A missing or invalid URL is reported before token.cmd gets to prompt.
91	cfg, err := client.NewConfig(rawURL, "")
92	if err != nil {
93		return client.Config{}, err
94	}
95	cfg.Token = strings.TrimSpace(e.getenv(client.EnvToken))
96	if cfg.Token == "" && runTokenCmd {
97		if cfg.Token, err = e.tokenFromCmd(cmd, warn); err != nil {
98			return client.Config{}, err
99		}
100	}
101	cfg.UserAgent = e.userAgent()
102	return cfg, nil
103}
104
105// client builds the API client. It warns when the token would travel in the
106// clear.
107func (e *cliEnv) client(cmd *cobra.Command) (*client.Client, error) {
108	cfg, err := e.buildConfig(cmd, true, true)
109	if err != nil {
110		return nil, err
111	}
112	return newClient(cmd, cfg, true)
113}
114
115// clientNoTokenCmd is client without running token.cmd (ERMITE_TOKEN is still
116// used), for GetVersion, which is anonymous, and for completions.
117func (e *cliEnv) clientNoTokenCmd(cmd *cobra.Command, warn bool) (*client.Client, error) {
118	cfg, err := e.buildConfig(cmd, warn, false)
119	if err != nil {
120		return nil, err
121	}
122	return newClient(cmd, cfg, warn)
123}
124
125func newClient(cmd *cobra.Command, cfg client.Config, warn bool) (*client.Client, error) {
126	if w := cfg.InsecureTokenWarning(); w != "" && warn {
127		warnf(cmd, "%s", w)
128	}
129	return client.New(cfg)
130}
131
132// warnf prints "ermite: warning: …" on stderr.
133func warnf(cmd *cobra.Command, format string, args ...any) {
134	_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ermite: warning: %s\n", fmt.Sprintf(format, args...))
135}
136
137// noticef prints "ermite: …" on stderr.
138func noticef(cmd *cobra.Command, format string, args ...any) {
139	_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ermite: %s\n", fmt.Sprintf(format, args...))
140}
141
142// addJSONFlag registers --json and returns its value.
143func addJSONFlag(cmd *cobra.Command) *bool {
144	return cmd.Flags().Bool("json", false, "print machine-readable JSON (fields are listed in --help)")
145}
146
147// writeNDJSON prints v as one compact JSON line.
148func writeNDJSON(w io.Writer, v any) error {
149	return json.NewEncoder(w).Encode(v)
150}
151
152// writeJSON prints v as indented JSON.
153func writeJSON(w io.Writer, v any) error {
154	enc := json.NewEncoder(w)
155	enc.SetIndent("", "  ")
156	return enc.Encode(v)
157}
158
159// clean makes a server-provided string safe for a terminal.
160func clean(s string) string { return client.Clean(s) }
161
162// collect reads every page: fetch gets the page token and returns the items
163// and the next page token ("" at the end).
164func collect[T any](ctx context.Context, fetch func(token string) ([]T, string, error)) ([]T, error) {
165	var all []T
166	token := ""
167	for {
168		if err := ctx.Err(); err != nil {
169			return nil, err
170		}
171		items, next, err := fetch(token)
172		if err != nil {
173			return nil, err
174		}
175		all = append(all, items...)
176		if next == "" {
177			return all, nil
178		}
179		token = next
180	}
181}
182
183// completionClient is the client of the shell completions: silent, and
184// anonymous unless ERMITE_TOKEN is set, because completing must never run
185// token.cmd (it may prompt, and a completion script cannot answer).
186func (e *cliEnv) completionClient(cmd *cobra.Command) (*client.Client, error) {
187	return e.clientNoTokenCmd(cmd, false)
188}
189
190// complete builds a shell completion: it opens a client (silently giving up
191// on any failure), lists with a 3 s timeout and keeps the entries whose
192// first element starts with the prefix.
193func complete[T any](env *cliEnv, cmd *cobra.Command, prefix string,
194	list func(context.Context, *client.Client) ([]T, error), entry func(T) (key, text string),
195) []string {
196	c, err := env.completionClient(cmd)
197	if err != nil {
198		return nil
199	}
200	ctx, cancel := context.WithTimeout(env.ctx(cmd), 3*time.Second)
201	defer cancel()
202	items, err := list(ctx, c)
203	if err != nil {
204		return nil
205	}
206	var out []string
207	for _, it := range items {
208		if k, text := entry(it); strings.HasPrefix(k, prefix) {
209			out = append(out, k+"\t"+text)
210		}
211	}
212	return out
213}
214
215// table prints aligned columns under upper-case headers.
216func table(w io.Writer, header []string, rows [][]string) error {
217	tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
218	_, _ = fmt.Fprintln(tw, strings.Join(header, "\t"))
219	for _, r := range rows {
220		cells := make([]string, len(r))
221		for i, c := range r {
222			cells[i] = clean(c)
223		}
224		_, _ = fmt.Fprintln(tw, strings.Join(cells, "\t"))
225	}
226	return tw.Flush()
227}
228
229// fields prints "name  value" pairs aligned on the names.
230func fields(w io.Writer, pairs [][2]string) error {
231	tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
232	for _, p := range pairs {
233		_, _ = fmt.Fprintf(tw, "%s\t%s\n", p[0], clean(p[1]))
234	}
235	return tw.Flush()
236}
237
238// timeFormat is how tables show instants, in UTC.
239const timeFormat = "2006-01-02 15:04"
240
241// jsonTime is an RFC 3339 UTC string, or nil (JSON null) for an unset timestamp.
242func jsonTime(t *timestamppb.Timestamp) *string {
243	if !t.IsValid() {
244		return nil
245	}
246	s := t.AsTime().UTC().Format(time.RFC3339)
247	return &s
248}
249
250// shortTime is a table cell for a timestamp, or fallback when unset.
251func shortTime(t *timestamppb.Timestamp, fallback string) string {
252	if !t.IsValid() {
253		return fallback
254	}
255	return t.AsTime().UTC().Format(timeFormat)
256}

Keyboard shortcuts

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

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

Branches 2

Tags 43

↑↓ moveenter openesc close