detached 0587f1c

feat(cli): add config show --json and warn when token.cmd would be refused

config show and config set warn when the permissions of the file or its directory would make token.cmd not run. config unset says when the key was not set. config show --json prints file, url and token_cmd.
commit
0587f1c18490e924236373a79da7396163963ad3
author
brice · · 2026-10-12 16:17 UTC
parent
323ab4e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +171 -37
M internal/app/cli_config.go +72 -30
@@ -6,7 +6,7 @@ import (
66 "errors"
77 "fmt"
88 "io/fs"
9- "os"
9+ "slices"
1010 "strings"
1111
1212 "github.com/bnema/kvconf"
@@ -36,46 +36,72 @@ func newConfigCmd(env *cliEnv) *cobra.Command {
3636 "take precedence over the file. token.cmd belongs to the server of the file: it is not run when\n" +
3737 "ERMITE_URL names another server than the url of the file (the call is then anonymous, with a\n" +
3838 "notice: set ERMITE_TOKEN). `ermite version`, `ermite config` and shell completion never run it\n" +
39- "(completion stays anonymous unless ERMITE_TOKEN is set).",
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).",
4043 }
4144 cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env))
4245 return cmd
4346 }
4447
48+// jsonConfig is the stable JSON shape of `ermite config show --json`.
49+type jsonConfig struct {
50+ File string `json:"file"`
51+ URL *string `json:"url"`
52+ TokenCmd *string `json:"token_cmd"`
53+}
54+
4555 func newConfigShowCmd(env *cliEnv) *cobra.Command {
46- return &cobra.Command{
56+ cmd := &cobra.Command{
4757 Use: "show",
4858 Short: "Print the config file path and its settings",
4959 Long: "Print the path of the config file, then the url and token.cmd stored in it ((not set) when\n" +
50- "absent). It never runs token.cmd.",
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.",
5162 Args: cobra.NoArgs,
52- RunE: func(cmd *cobra.Command, _ []string) error {
53- f, path, err := env.readFile(cmd, true)
54- if path == "" {
55- p, perr := env.path()
56- if perr != nil {
57- return perr
58- }
59- path = p
60- }
61- if err != nil {
62- // Say which file is unreadable.
63- _ = fields(cmd.OutOrStdout(), [][2]string{{"file", path}})
64- return err
65- }
66- if strings.TrimSpace(env.getenv(client.EnvURL)) != "" && f.URL != "" {
67- noticef(cmd, "%s is set and takes precedence over url", client.EnvURL)
68- }
69- if strings.TrimSpace(env.getenv(client.EnvToken)) != "" && f.TokenCmd != "" {
70- noticef(cmd, "%s is set: token.cmd is not used", client.EnvToken)
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
7171 }
72- return fields(cmd.OutOrStdout(), [][2]string{
73- {"file", path},
74- {keyURL, orNotSet(f.URL)},
75- {keyTokenCmd, orNotSet(f.TokenCmd)},
76- })
77- },
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+
100+func nilIfBlank(s string) *string {
101+ if strings.TrimSpace(s) == "" {
102+ return nil
78103 }
104+ return &s
79105 }
80106
81107 func orNotSet(s string) string {
@@ -122,6 +148,10 @@ func newConfigSetCmd(env *cliEnv) *cobra.Command {
122148 return fmt.Errorf("writing %s: %w", path, err)
123149 }
124150 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+ }
125155 return nil
126156 },
127157 }
@@ -142,10 +172,22 @@ func newConfigUnsetCmd(env *cliEnv) *cobra.Command {
142172 if err != nil {
143173 return err
144174 }
145- if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
175+ data, err := kvconf.ReadFile(path, kvconf.Options{})
176+ if errors.Is(err, fs.ErrNotExist) {
146177 noticef(cmd, "%s is not set: %s does not exist", key, path)
147178 return nil
148179 }
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+ }
149191 if err := kvconf.SetFile(path, map[string]*string{key: nil}, 0o600); err != nil {
150192 return fmt.Errorf("writing %s: %w", path, err)
151193 }
@@ -6,7 +6,7 @@ import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "io/fs"8 "io/fs"
9- "os"9+ "slices"
10 "strings"10 "strings"
11 11
12 "github.com/bnema/kvconf"12 "github.com/bnema/kvconf"
@@ -36,46 +36,72 @@ func newConfigCmd(env *cliEnv) *cobra.Command {
36 "take precedence over the file. token.cmd belongs to the server of the file: it is not run when\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" +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" +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).",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).",
40 }43 }
41 cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env))44 cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env))
42 return cmd45 return cmd
43 }46 }
44 47
48+// jsonConfig is the stable JSON shape of `ermite config show --json`.
49+type jsonConfig struct {
50+ File string `json:"file"`
51+ URL *string `json:"url"`
52+ TokenCmd *string `json:"token_cmd"`
53+}
54+
45 func newConfigShowCmd(env *cliEnv) *cobra.Command {55 func newConfigShowCmd(env *cliEnv) *cobra.Command {
46- return &cobra.Command{56+ cmd := &cobra.Command{
47 Use: "show",57 Use: "show",
48 Short: "Print the config file path and its settings",58 Short: "Print the config file path and its settings",
49 Long: "Print the path of the config file, then the url and token.cmd stored in it ((not set) when\n" +59 Long: "Print the path of the config file, then the url and token.cmd stored in it ((not set) when\n" +
50- "absent). It never runs token.cmd.",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.",
51 Args: cobra.NoArgs,62 Args: cobra.NoArgs,
52- RunE: func(cmd *cobra.Command, _ []string) error {63+ }
53- f, path, err := env.readFile(cmd, true)64+ asJSON := addJSONFlag(cmd)
54- if path == "" {65+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
55- p, perr := env.path()66+ f, path, err := env.readFile(cmd, true)
56- if perr != nil {67+ if path == "" {
57- return perr68+ p, perr := env.path()
58- }69+ if perr != nil {
59- path = p70+ return perr
60- }
61- if err != nil {
62- // Say which file is unreadable.
63- _ = fields(cmd.OutOrStdout(), [][2]string{{"file", path}})
64- return err
65- }
66- if strings.TrimSpace(env.getenv(client.EnvURL)) != "" && f.URL != "" {
67- noticef(cmd, "%s is set and takes precedence over url", client.EnvURL)
68- }
69- if strings.TrimSpace(env.getenv(client.EnvToken)) != "" && f.TokenCmd != "" {
70- noticef(cmd, "%s is set: token.cmd is not used", client.EnvToken)
71 }71 }
72- return fields(cmd.OutOrStdout(), [][2]string{72+ path = p
73- {"file", path},73+ }
74- {keyURL, orNotSet(f.URL)},74+ if err != nil {
75- {keyTokenCmd, orNotSet(f.TokenCmd)},75+ // Say which file is unreadable.
76- })76+ _ = fields(cmd.OutOrStdout(), [][2]string{{"file", path}})
77- },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+
100+func nilIfBlank(s string) *string {
101+ if strings.TrimSpace(s) == "" {
102+ return nil
78 }103 }
104+ return &s
79 }105 }
80 106
81 func orNotSet(s string) string {107 func orNotSet(s string) string {
@@ -122,6 +148,10 @@ func newConfigSetCmd(env *cliEnv) *cobra.Command {
122 return fmt.Errorf("writing %s: %w", path, err)148 return fmt.Errorf("writing %s: %w", path, err)
123 }149 }
124 noticef(cmd, "%s set in %s", key, path)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+ }
125 return nil155 return nil
126 },156 },
127 }157 }
@@ -142,10 +172,22 @@ func newConfigUnsetCmd(env *cliEnv) *cobra.Command {
142 if err != nil {172 if err != nil {
143 return err173 return err
144 }174 }
145- if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {175+ data, err := kvconf.ReadFile(path, kvconf.Options{})
176+ if errors.Is(err, fs.ErrNotExist) {
146 noticef(cmd, "%s is not set: %s does not exist", key, path)177 noticef(cmd, "%s is not set: %s does not exist", key, path)
147 return nil178 return nil
148 }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+ }
149 if err := kvconf.SetFile(path, map[string]*string{key: nil}, 0o600); err != nil {191 if err := kvconf.SetFile(path, map[string]*string{key: nil}, 0o600); err != nil {
150 return fmt.Errorf("writing %s: %w", path, err)192 return fmt.Errorf("writing %s: %w", path, err)
151 }193 }
M internal/app/cli_config_test.go +99 -7
@@ -3,8 +3,12 @@
33 package app
44
55 import (
6+ "context"
7+ "errors"
68 "os"
9+ "path/filepath"
710 "runtime"
11+ "strconv"
812 "strings"
913 "testing"
1014
@@ -121,14 +125,17 @@ func TestConfigShowNeverRunsTokenCmdAndNotesPrecedence(t *testing.T) {
121125 assert.False(t, strings.Contains(res.stdout+res.stderr, "ermite_x"), "the token is never printed")
122126 }
123127
124-func TestConfigHelpDocumentsTheFile(t *testing.T) {
125- res := runCLI(t, nil, "", "--help").ok(t)
126- for _, s := range []string{"ERMITE_URL", "ERMITE_TOKEN", "token.cmd", "ERMITE_CONFIG", "ermite config"} {
127- assert.Contains(t, res.stdout, s)
128+func TestHelpDocumentsTheConfigFileContract(t *testing.T) {
129+ root := runCLI(t, nil, "", "--help").ok(t).stdout
130+ cfg := runCLI(t, nil, "", "config", "--help").ok(t).stdout
131+ for name, help := range map[string]string{"root": root, "config": cfg} {
132+ assert.Contains(t, help, "ermite/config", "%s help names the file", name)
133+ assert.Contains(t, help, "ERMITE_CONFIG", "%s help names the override", name)
134+ assert.Contains(t, help, "ERMITE_URL", "%s help states the precedence", name)
135+ assert.Contains(t, help, "ERMITE_TOKEN", "%s help states the precedence", name)
128136 }
129- res = runCLI(t, nil, "", "config", "--help").ok(t)
130- assert.Contains(t, res.stdout, "pass show ermite/token")
131- assert.Contains(t, res.stdout, "https://git.example.com")
137+ assert.Contains(t, cfg, "token.cmd = ", "the config help has an example")
138+ assert.Contains(t, cfg, "Windows", "the config help states the Windows limit")
132139 }
133140
134141 func TestConfigShowNamesTheFileItCannotRead(t *testing.T) {
@@ -141,3 +148,88 @@ func TestConfigShowNamesTheFileItCannotRead(t *testing.T) {
141148 assert.Contains(t, res.stdout, path)
142149 assert.Contains(t, res.err.Error(), "config:")
143150 }
151+
152+func TestConfigUnsetOfAnAbsentKey(t *testing.T) {
153+ env := testEnv(t, nil)
154+ path := writeConfigFile(t, env, "# only a comment\nurl = https://git.example.com\n", 0o600)
155+ res := runCLIWith(t, env, "", "config", "unset", "token.cmd").ok(t)
156+ assert.Equal(t, "ermite: token.cmd is not set\n", res.stderr)
157+ data, err := os.ReadFile(path)
158+ require.NoError(t, err)
159+ assert.Equal(t, "# only a comment\nurl = https://git.example.com\n", string(data), "the file is untouched")
160+}
161+
162+func TestConfigShowJSON(t *testing.T) {
163+ env := testEnv(t, nil)
164+ path, _ := env.configPath()
165+ res := runCLIWith(t, env, "", "config", "show", "--json").ok(t)
166+ assert.JSONEq(t, `{"file":`+strconv.Quote(path)+`,"url":null,"token_cmd":null}`, res.stdout)
167+
168+ env = testEnv(t, nil)
169+ path = writeConfigFile(t, env, "url = https://git.example.com\ntoken.cmd = pass show ermite/token\n", 0o600)
170+ res = runCLIWith(t, env, "", "config", "show", "--json").ok(t) // testEnv fails the test if token.cmd runs
171+ assert.JSONEq(t, `{"file":`+strconv.Quote(path)+`,"url":"https://git.example.com","token_cmd":"pass show ermite/token"}`, res.stdout)
172+}
173+
174+func TestConfigShowAndSetWarnWhenTokenCmdWouldBeRefused(t *testing.T) {
175+ if runtime.GOOS == "windows" {
176+ t.Skip("no Unix permission bits")
177+ }
178+ env := testEnv(t, nil)
179+ path := writeConfigFile(t, env, "url = https://git.example.com\ntoken.cmd = get-token\n", 0o666)
180+
181+ res := runCLIWith(t, env, "", "config", "show").ok(t)
182+ assert.Contains(t, res.stderr, "ermite: warning: config file "+path+" is writable by others: token.cmd not run")
183+
184+ // a fresh process; set keeps the mode of an existing file
185+ res = runCLIWith(t, testEnvAt(t, path), "", "config", "set", "url", "https://git2.example.com").ok(t)
186+ assert.Contains(t, res.stderr, "writable by others")
187+
188+ // no warning without a token.cmd, or with a private file
189+ env = testEnv(t, nil)
190+ writeConfigFile(t, env, "url = https://git.example.com\n", 0o666)
191+ assert.NotContains(t, runCLIWith(t, env, "", "config", "show").ok(t).stderr, "warning")
192+ res = runCLIWith(t, testEnv(t, nil), "", "config", "set", "token.cmd", "get-token").ok(t)
193+ assert.NotContains(t, res.stderr, "warning")
194+}
195+
196+func TestDefaultConfigPathUsesXDGConfigHome(t *testing.T) {
197+ xdg := t.TempDir()
198+ t.Setenv("XDG_CONFIG_HOME", xdg)
199+ t.Setenv("ERMITE_CONFIG", "")
200+ f := listFake()
201+
202+ env := defaultCLIEnv() // the real path resolution, the real environment
203+ res := runCLIWith(t, env, "", "config", "set", "url", f.serve(t)).ok(t)
204+ want := filepath.Join(xdg, "ermite", "config")
205+ assert.Contains(t, res.stderr, want)
206+ info, err := os.Stat(want)
207+ require.NoError(t, err)
208+ if runtime.GOOS != "windows" {
209+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
210+ }
211+
212+ env = defaultCLIEnv()
213+ env.runTokenCmd = func(context.Context, string) (string, error) { return "", errors.New("must not run") }
214+ t.Setenv("ERMITE_URL", "")
215+ t.Setenv("ERMITE_TOKEN", "")
216+ runCLIWith(t, env, "", "repo", "list").ok(t)
217+ assert.Equal(t, 1, callCount(f, "ListRepos"))
218+
219+ // ERMITE_CONFIG wins over the default
220+ other := filepath.Join(t.TempDir(), "other")
221+ t.Setenv("ERMITE_CONFIG", other)
222+ res = runCLIWith(t, defaultCLIEnv(), "", "config", "show").ok(t)
223+ assert.Contains(t, res.stdout, other)
224+}
225+
226+func callCount(f *fakeAPI, name string) int {
227+ calls, _ := f.snapshot()
228+ n := 0
229+ for _, c := range calls {
230+ if c == name {
231+ n++
232+ }
233+ }
234+ return n
235+}
@@ -3,8 +3,12 @@
3 package app3 package app
4 4
5 import (5 import (
6+ "context"
7+ "errors"
6 "os"8 "os"
9+ "path/filepath"
7 "runtime"10 "runtime"
11+ "strconv"
8 "strings"12 "strings"
9 "testing"13 "testing"
10 14
@@ -121,14 +125,17 @@ func TestConfigShowNeverRunsTokenCmdAndNotesPrecedence(t *testing.T) {
121 assert.False(t, strings.Contains(res.stdout+res.stderr, "ermite_x"), "the token is never printed")125 assert.False(t, strings.Contains(res.stdout+res.stderr, "ermite_x"), "the token is never printed")
122 }126 }
123 127
124-func TestConfigHelpDocumentsTheFile(t *testing.T) {128+func TestHelpDocumentsTheConfigFileContract(t *testing.T) {
125- res := runCLI(t, nil, "", "--help").ok(t)129+ root := runCLI(t, nil, "", "--help").ok(t).stdout
126- for _, s := range []string{"ERMITE_URL", "ERMITE_TOKEN", "token.cmd", "ERMITE_CONFIG", "ermite config"} {130+ cfg := runCLI(t, nil, "", "config", "--help").ok(t).stdout
127- assert.Contains(t, res.stdout, s)131+ for name, help := range map[string]string{"root": root, "config": cfg} {
132+ assert.Contains(t, help, "ermite/config", "%s help names the file", name)
133+ assert.Contains(t, help, "ERMITE_CONFIG", "%s help names the override", name)
134+ assert.Contains(t, help, "ERMITE_URL", "%s help states the precedence", name)
135+ assert.Contains(t, help, "ERMITE_TOKEN", "%s help states the precedence", name)
128 }136 }
129- res = runCLI(t, nil, "", "config", "--help").ok(t)137+ assert.Contains(t, cfg, "token.cmd = ", "the config help has an example")
130- assert.Contains(t, res.stdout, "pass show ermite/token")138+ assert.Contains(t, cfg, "Windows", "the config help states the Windows limit")
131- assert.Contains(t, res.stdout, "https://git.example.com")
132 }139 }
133 140
134 func TestConfigShowNamesTheFileItCannotRead(t *testing.T) {141 func TestConfigShowNamesTheFileItCannotRead(t *testing.T) {
@@ -141,3 +148,88 @@ func TestConfigShowNamesTheFileItCannotRead(t *testing.T) {
141 assert.Contains(t, res.stdout, path)148 assert.Contains(t, res.stdout, path)
142 assert.Contains(t, res.err.Error(), "config:")149 assert.Contains(t, res.err.Error(), "config:")
143 }150 }
151+
152+func TestConfigUnsetOfAnAbsentKey(t *testing.T) {
153+ env := testEnv(t, nil)
154+ path := writeConfigFile(t, env, "# only a comment\nurl = https://git.example.com\n", 0o600)
155+ res := runCLIWith(t, env, "", "config", "unset", "token.cmd").ok(t)
156+ assert.Equal(t, "ermite: token.cmd is not set\n", res.stderr)
157+ data, err := os.ReadFile(path)
158+ require.NoError(t, err)
159+ assert.Equal(t, "# only a comment\nurl = https://git.example.com\n", string(data), "the file is untouched")
160+}
161+
162+func TestConfigShowJSON(t *testing.T) {
163+ env := testEnv(t, nil)
164+ path, _ := env.configPath()
165+ res := runCLIWith(t, env, "", "config", "show", "--json").ok(t)
166+ assert.JSONEq(t, `{"file":`+strconv.Quote(path)+`,"url":null,"token_cmd":null}`, res.stdout)
167+
168+ env = testEnv(t, nil)
169+ path = writeConfigFile(t, env, "url = https://git.example.com\ntoken.cmd = pass show ermite/token\n", 0o600)
170+ res = runCLIWith(t, env, "", "config", "show", "--json").ok(t) // testEnv fails the test if token.cmd runs
171+ assert.JSONEq(t, `{"file":`+strconv.Quote(path)+`,"url":"https://git.example.com","token_cmd":"pass show ermite/token"}`, res.stdout)
172+}
173+
174+func TestConfigShowAndSetWarnWhenTokenCmdWouldBeRefused(t *testing.T) {
175+ if runtime.GOOS == "windows" {
176+ t.Skip("no Unix permission bits")
177+ }
178+ env := testEnv(t, nil)
179+ path := writeConfigFile(t, env, "url = https://git.example.com\ntoken.cmd = get-token\n", 0o666)
180+
181+ res := runCLIWith(t, env, "", "config", "show").ok(t)
182+ assert.Contains(t, res.stderr, "ermite: warning: config file "+path+" is writable by others: token.cmd not run")
183+
184+ // a fresh process; set keeps the mode of an existing file
185+ res = runCLIWith(t, testEnvAt(t, path), "", "config", "set", "url", "https://git2.example.com").ok(t)
186+ assert.Contains(t, res.stderr, "writable by others")
187+
188+ // no warning without a token.cmd, or with a private file
189+ env = testEnv(t, nil)
190+ writeConfigFile(t, env, "url = https://git.example.com\n", 0o666)
191+ assert.NotContains(t, runCLIWith(t, env, "", "config", "show").ok(t).stderr, "warning")
192+ res = runCLIWith(t, testEnv(t, nil), "", "config", "set", "token.cmd", "get-token").ok(t)
193+ assert.NotContains(t, res.stderr, "warning")
194+}
195+
196+func TestDefaultConfigPathUsesXDGConfigHome(t *testing.T) {
197+ xdg := t.TempDir()
198+ t.Setenv("XDG_CONFIG_HOME", xdg)
199+ t.Setenv("ERMITE_CONFIG", "")
200+ f := listFake()
201+
202+ env := defaultCLIEnv() // the real path resolution, the real environment
203+ res := runCLIWith(t, env, "", "config", "set", "url", f.serve(t)).ok(t)
204+ want := filepath.Join(xdg, "ermite", "config")
205+ assert.Contains(t, res.stderr, want)
206+ info, err := os.Stat(want)
207+ require.NoError(t, err)
208+ if runtime.GOOS != "windows" {
209+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
210+ }
211+
212+ env = defaultCLIEnv()
213+ env.runTokenCmd = func(context.Context, string) (string, error) { return "", errors.New("must not run") }
214+ t.Setenv("ERMITE_URL", "")
215+ t.Setenv("ERMITE_TOKEN", "")
216+ runCLIWith(t, env, "", "repo", "list").ok(t)
217+ assert.Equal(t, 1, callCount(f, "ListRepos"))
218+
219+ // ERMITE_CONFIG wins over the default
220+ other := filepath.Join(t.TempDir(), "other")
221+ t.Setenv("ERMITE_CONFIG", other)
222+ res = runCLIWith(t, defaultCLIEnv(), "", "config", "show").ok(t)
223+ assert.Contains(t, res.stdout, other)
224+}
225+
226+func callCount(f *fakeAPI, name string) int {
227+ calls, _ := f.snapshot()
228+ n := 0
229+ for _, c := range calls {
230+ if c == name {
231+ n++
232+ }
233+ }
234+ return n
235+}

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 45

↑↓ moveenter openesc close