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}