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}