v0.28.1 cc7ba64

cli_config.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"errors"
7	"fmt"
8	"io/fs"
9	"slices"
10	"strings"
11
12	"github.com/bnema/kvconf"
13	"github.com/spf13/cobra"
14
15	"bnema.dev/ermite/internal/adapters/client"
16)
17
18// notSet is what `config show` prints for a key without a value.
19const notSet = "(not set)"
20
21// configKeys are the keys of the CLI config file.
22var configKeys = []string{keyURL, keyTokenCmd}
23
24func newConfigCmd(env *cliEnv) *cobra.Command {
25	cmd := &cobra.Command{
26		Use:   "config",
27		Short: "Show and edit the config file of the remote commands",
28		Long: "The remote commands read the server address and a way to get a token from a config file,\n" +
29			"~/.config/ermite/config (the config directory of the platform on macOS and Windows; or the file\n" +
30			"named by ERMITE_CONFIG; `ermite config show` prints the path in use):\n\n" +
31			"  url       = https://git.example.com\n" +
32			"  token.cmd = pass show ermite/token\n\n" +
33			"token.cmd is a command line run with the system shell when a command needs the token (at most\n" +
34			"once per run): it must print the token alone on one line. Use any secret manager (pass, gopass,\n" +
35			"op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" +
36			"take precedence over the file. token.cmd belongs to the server of the file: it is not run when\n" +
37			"ERMITE_URL names another server than the url of the file (the call is then anonymous, with a\n" +
38			"notice: set ERMITE_TOKEN). `ermite version`, `ermite config` and shell completion never run it\n" +
39			"(completion stays anonymous unless ERMITE_TOKEN is set).\n\n" +
40			"token.cmd is also not run when the file belongs to another user, or when the file or its directory\n" +
41			"can be written by other users (Unix); `ermite config show` and `set` warn about it. On Windows\n" +
42			"this check does not exist: keep the file in your own profile (the default place).",
43	}
44	cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env))
45	return cmd
46}
47
48// jsonConfig is the stable JSON shape of `ermite config show --json`.
49type jsonConfig struct {
50	File     string  `json:"file"`
51	URL      *string `json:"url"`
52	TokenCmd *string `json:"token_cmd"`
53}
54
55func newConfigShowCmd(env *cliEnv) *cobra.Command {
56	cmd := &cobra.Command{
57		Use:   "show",
58		Short: "Print the config file path and its settings",
59		Long: "Print the path of the config file, then the url and token.cmd stored in it ((not set) when\n" +
60			"absent). It never runs token.cmd, and warns when token.cmd would be refused (file permissions).\n\n" +
61			"JSON fields (--json): file; url and token_cmd, null when not set.",
62		Args: cobra.NoArgs,
63	}
64	asJSON := addJSONFlag(cmd)
65	cmd.RunE = func(cmd *cobra.Command, _ []string) error {
66		f, path, err := env.readFile(cmd, true)
67		if path == "" {
68			p, perr := env.path()
69			if perr != nil {
70				return perr
71			}
72			path = p
73		}
74		if err != nil {
75			// Say which file is unreadable.
76			_ = fields(cmd.OutOrStdout(), [][2]string{{"file", path}})
77			return err
78		}
79		if strings.TrimSpace(env.getenv(client.EnvURL)) != "" && f.URL != "" {
80			noticef(cmd, "%s is set and takes precedence over url", client.EnvURL)
81		}
82		if strings.TrimSpace(env.getenv(client.EnvToken)) != "" && f.TokenCmd != "" {
83			noticef(cmd, "%s is set: token.cmd is not used", client.EnvToken)
84		}
85		if f.TokenCmd != "" && env.fileTrust != nil {
86			warnf(cmd, "%s", env.fileTrust)
87		}
88		if *asJSON {
89			return writeJSON(cmd.OutOrStdout(), jsonConfig{File: path, URL: nilIfBlank(f.URL), TokenCmd: nilIfBlank(f.TokenCmd)})
90		}
91		return fields(cmd.OutOrStdout(), [][2]string{
92			{"file", path},
93			{keyURL, orNotSet(f.URL)},
94			{keyTokenCmd, orNotSet(f.TokenCmd)},
95		})
96	}
97	return cmd
98}
99
100func nilIfBlank(s string) *string {
101	if strings.TrimSpace(s) == "" {
102		return nil
103	}
104	return &s
105}
106
107func orNotSet(s string) string {
108	if strings.TrimSpace(s) == "" {
109		return notSet
110	}
111	return s
112}
113
114func newConfigSetCmd(env *cliEnv) *cobra.Command {
115	return &cobra.Command{
116		Use:   "set <url|token.cmd> <value>",
117		Short: "Set the server address or the token command in the config file",
118		Long: "Write a setting to the config file, keeping its comments, and create the file (mode 0600) if needed.\n" +
119			"url is checked: an http(s) address without credentials. token.cmd is a command line that prints the\n" +
120			"token, for example `pass show ermite/token`; it is stored as is and not run.",
121		Args:              cobra.ExactArgs(2),
122		ValidArgsFunction: completeConfigKeys,
123		RunE: func(cmd *cobra.Command, args []string) error {
124			key, value := args[0], strings.TrimSpace(args[1])
125			if err := checkConfigKey(key); err != nil {
126				return err
127			}
128			switch key {
129			case keyURL:
130				u, err := client.ParseURL(value)
131				if err != nil {
132					return fmt.Errorf("invalid url: %w", err)
133				}
134				value = u.String()
135			case keyTokenCmd:
136				if value == "" {
137					return errors.New("token.cmd is empty: use `ermite config unset token.cmd` to remove it")
138				}
139				if strings.ContainsAny(value, "\r\n") {
140					return errors.New("token.cmd must be a single line")
141				}
142			}
143			path, err := env.path()
144			if err != nil {
145				return err
146			}
147			if err := kvconf.SetFile(path, map[string]*string{key: &value}, 0o600); err != nil {
148				return fmt.Errorf("writing %s: %w", path, err)
149			}
150			noticef(cmd, "%s set in %s", key, path)
151			// Say now, not at the first command, that token.cmd would be refused.
152			if f, _, trust, err := loadConfigFile(path); err == nil && trust != nil && f.TokenCmd != "" {
153				warnf(cmd, "%s", trust)
154			}
155			return nil
156		},
157	}
158}
159
160func newConfigUnsetCmd(env *cliEnv) *cobra.Command {
161	return &cobra.Command{
162		Use:               "unset <url|token.cmd>",
163		Short:             "Remove a setting from the config file",
164		Args:              cobra.ExactArgs(1),
165		ValidArgsFunction: completeConfigKeys,
166		RunE: func(cmd *cobra.Command, args []string) error {
167			key := args[0]
168			if err := checkConfigKey(key); err != nil {
169				return err
170			}
171			path, err := env.path()
172			if err != nil {
173				return err
174			}
175			data, err := kvconf.ReadFile(path, kvconf.Options{})
176			if errors.Is(err, fs.ErrNotExist) {
177				noticef(cmd, "%s is not set: %s does not exist", key, path)
178				return nil
179			}
180			if err != nil {
181				return fmt.Errorf("reading %s: %w", path, err)
182			}
183			entries, _, err := kvconf.ParseBytes(data, kvconf.Options{})
184			if err != nil {
185				return fmt.Errorf("reading %s: %w", path, err)
186			}
187			if !slices.ContainsFunc(entries, func(e kvconf.Entry) bool { return e.Key == key }) {
188				noticef(cmd, "%s is not set", key)
189				return nil
190			}
191			if err := kvconf.SetFile(path, map[string]*string{key: nil}, 0o600); err != nil {
192				return fmt.Errorf("writing %s: %w", path, err)
193			}
194			noticef(cmd, "%s removed from %s", key, path)
195			return nil
196		},
197	}
198}
199
200func checkConfigKey(key string) error {
201	for _, k := range configKeys {
202		if key == k {
203			return nil
204		}
205	}
206	return fmt.Errorf("unknown key %q: the keys are %s", key, strings.Join(configKeys, " and "))
207}
208
209func completeConfigKeys(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
210	if len(args) > 0 {
211		return nil, cobra.ShellCompDirectiveNoFileComp
212	}
213	return configKeys, cobra.ShellCompDirectiveNoFileComp
214}

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 3

Tags 44

↑↓ moveenter openesc close