detached fc19772

feat(cli): add ermite config show, set and unset

set validates the url, keeps the comments of the file and creates it with mode 0600. show prints the stored values and never runs token.cmd.
commit
fc19772855169ef08622939c96317324a6144571
author
brice · · 2026-10-12 14:59 UTC
parent
c0892a5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +295 -1
A internal/app/cli_config.go +165 -0
@@ -0,0 +1,165 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "io/fs"
9+ "os"
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.
19+const notSet = "(not set)"
20+
21+// configKeys are the keys of the CLI config file.
22+var configKeys = []string{keyURL, keyTokenCmd}
23+
24+func 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 (or the file named by ERMITE_CONFIG):\n\n" +
30+ " url = https://git.example.com\n" +
31+ " token.cmd = pass show ermite/token\n\n" +
32+ "token.cmd is a command line run with the system shell when a command needs the token (at most\n" +
33+ "once per run): it must print the token alone on one line. Use any secret manager (pass, gopass,\n" +
34+ "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" +
35+ "take precedence over the file; `ermite version` and `ermite config` never run token.cmd. The\n" +
36+ "command is not run when the file is writable by other users.",
37+ }
38+ cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env))
39+ return cmd
40+}
41+
42+func newConfigShowCmd(env *cliEnv) *cobra.Command {
43+ return &cobra.Command{
44+ Use: "show",
45+ Short: "Print the config file path and its settings",
46+ Long: "Print the path of the config file, then the url and token.cmd stored in it ((not set) when\n" +
47+ "absent). It never runs token.cmd.",
48+ Args: cobra.NoArgs,
49+ RunE: func(cmd *cobra.Command, _ []string) error {
50+ f, path, err := env.readFile(cmd, true)
51+ if err != nil {
52+ return err
53+ }
54+ if path == "" {
55+ if path, err = env.path(); err != nil {
56+ return err
57+ }
58+ }
59+ if env.getenv(client.EnvURL) != "" && f.URL != "" {
60+ noticef(cmd, "%s is set and takes precedence over url", client.EnvURL)
61+ }
62+ if env.getenv(client.EnvToken) != "" && f.TokenCmd != "" {
63+ noticef(cmd, "%s is set: token.cmd is not used", client.EnvToken)
64+ }
65+ return fields(cmd.OutOrStdout(), [][2]string{
66+ {"file", path},
67+ {keyURL, orNotSet(f.URL)},
68+ {keyTokenCmd, orNotSet(f.TokenCmd)},
69+ })
70+ },
71+ }
72+}
73+
74+func orNotSet(s string) string {
75+ if strings.TrimSpace(s) == "" {
76+ return notSet
77+ }
78+ return s
79+}
80+
81+func newConfigSetCmd(env *cliEnv) *cobra.Command {
82+ return &cobra.Command{
83+ Use: "set <url|token.cmd> <value>",
84+ Short: "Set the server address or the token command in the config file",
85+ Long: "Write a setting to the config file, keeping its comments, and create the file (mode 0600) if needed.\n" +
86+ "url is checked: an http(s) address without credentials. token.cmd is a command line that prints the\n" +
87+ "token, for example `pass show ermite/token`; it is stored as is and not run.",
88+ Args: cobra.ExactArgs(2),
89+ ValidArgsFunction: completeConfigKeys,
90+ RunE: func(cmd *cobra.Command, args []string) error {
91+ key, value := args[0], strings.TrimSpace(args[1])
92+ if err := checkConfigKey(key); err != nil {
93+ return err
94+ }
95+ switch key {
96+ case keyURL:
97+ u, err := client.ParseURL(value)
98+ if err != nil {
99+ return fmt.Errorf("invalid url: %w", err)
100+ }
101+ value = u.String()
102+ case keyTokenCmd:
103+ if value == "" {
104+ return errors.New("token.cmd is empty: use `ermite config unset token.cmd` to remove it")
105+ }
106+ if strings.ContainsAny(value, "\r\n") {
107+ return errors.New("token.cmd must be a single line")
108+ }
109+ }
110+ path, err := env.path()
111+ if err != nil {
112+ return err
113+ }
114+ if err := kvconf.SetFile(path, map[string]*string{key: &value}, 0o600); err != nil {
115+ return fmt.Errorf("writing %s: %w", path, err)
116+ }
117+ noticef(cmd, "%s set in %s", key, path)
118+ return nil
119+ },
120+ }
121+}
122+
123+func newConfigUnsetCmd(env *cliEnv) *cobra.Command {
124+ return &cobra.Command{
125+ Use: "unset <url|token.cmd>",
126+ Short: "Remove a setting from the config file",
127+ Args: cobra.ExactArgs(1),
128+ ValidArgsFunction: completeConfigKeys,
129+ RunE: func(cmd *cobra.Command, args []string) error {
130+ key := args[0]
131+ if err := checkConfigKey(key); err != nil {
132+ return err
133+ }
134+ path, err := env.path()
135+ if err != nil {
136+ return err
137+ }
138+ if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
139+ noticef(cmd, "%s is not set: %s does not exist", key, path)
140+ return nil
141+ }
142+ if err := kvconf.SetFile(path, map[string]*string{key: nil}, 0o600); err != nil {
143+ return fmt.Errorf("writing %s: %w", path, err)
144+ }
145+ noticef(cmd, "%s removed from %s", key, path)
146+ return nil
147+ },
148+ }
149+}
150+
151+func checkConfigKey(key string) error {
152+ for _, k := range configKeys {
153+ if key == k {
154+ return nil
155+ }
156+ }
157+ return fmt.Errorf("unknown key %q: the keys are %s", key, strings.Join(configKeys, " and "))
158+}
159+
160+func completeConfigKeys(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
161+ if len(args) > 0 {
162+ return nil, cobra.ShellCompDirectiveNoFileComp
163+ }
164+ return configKeys, cobra.ShellCompDirectiveNoFileComp
165+}
@@ -0,0 +1,165 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "io/fs"
9+ "os"
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.
19+const notSet = "(not set)"
20+
21+// configKeys are the keys of the CLI config file.
22+var configKeys = []string{keyURL, keyTokenCmd}
23+
24+func 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 (or the file named by ERMITE_CONFIG):\n\n" +
30+ " url = https://git.example.com\n" +
31+ " token.cmd = pass show ermite/token\n\n" +
32+ "token.cmd is a command line run with the system shell when a command needs the token (at most\n" +
33+ "once per run): it must print the token alone on one line. Use any secret manager (pass, gopass,\n" +
34+ "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" +
35+ "take precedence over the file; `ermite version` and `ermite config` never run token.cmd. The\n" +
36+ "command is not run when the file is writable by other users.",
37+ }
38+ cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env))
39+ return cmd
40+}
41+
42+func newConfigShowCmd(env *cliEnv) *cobra.Command {
43+ return &cobra.Command{
44+ Use: "show",
45+ Short: "Print the config file path and its settings",
46+ Long: "Print the path of the config file, then the url and token.cmd stored in it ((not set) when\n" +
47+ "absent). It never runs token.cmd.",
48+ Args: cobra.NoArgs,
49+ RunE: func(cmd *cobra.Command, _ []string) error {
50+ f, path, err := env.readFile(cmd, true)
51+ if err != nil {
52+ return err
53+ }
54+ if path == "" {
55+ if path, err = env.path(); err != nil {
56+ return err
57+ }
58+ }
59+ if env.getenv(client.EnvURL) != "" && f.URL != "" {
60+ noticef(cmd, "%s is set and takes precedence over url", client.EnvURL)
61+ }
62+ if env.getenv(client.EnvToken) != "" && f.TokenCmd != "" {
63+ noticef(cmd, "%s is set: token.cmd is not used", client.EnvToken)
64+ }
65+ return fields(cmd.OutOrStdout(), [][2]string{
66+ {"file", path},
67+ {keyURL, orNotSet(f.URL)},
68+ {keyTokenCmd, orNotSet(f.TokenCmd)},
69+ })
70+ },
71+ }
72+}
73+
74+func orNotSet(s string) string {
75+ if strings.TrimSpace(s) == "" {
76+ return notSet
77+ }
78+ return s
79+}
80+
81+func newConfigSetCmd(env *cliEnv) *cobra.Command {
82+ return &cobra.Command{
83+ Use: "set <url|token.cmd> <value>",
84+ Short: "Set the server address or the token command in the config file",
85+ Long: "Write a setting to the config file, keeping its comments, and create the file (mode 0600) if needed.\n" +
86+ "url is checked: an http(s) address without credentials. token.cmd is a command line that prints the\n" +
87+ "token, for example `pass show ermite/token`; it is stored as is and not run.",
88+ Args: cobra.ExactArgs(2),
89+ ValidArgsFunction: completeConfigKeys,
90+ RunE: func(cmd *cobra.Command, args []string) error {
91+ key, value := args[0], strings.TrimSpace(args[1])
92+ if err := checkConfigKey(key); err != nil {
93+ return err
94+ }
95+ switch key {
96+ case keyURL:
97+ u, err := client.ParseURL(value)
98+ if err != nil {
99+ return fmt.Errorf("invalid url: %w", err)
100+ }
101+ value = u.String()
102+ case keyTokenCmd:
103+ if value == "" {
104+ return errors.New("token.cmd is empty: use `ermite config unset token.cmd` to remove it")
105+ }
106+ if strings.ContainsAny(value, "\r\n") {
107+ return errors.New("token.cmd must be a single line")
108+ }
109+ }
110+ path, err := env.path()
111+ if err != nil {
112+ return err
113+ }
114+ if err := kvconf.SetFile(path, map[string]*string{key: &value}, 0o600); err != nil {
115+ return fmt.Errorf("writing %s: %w", path, err)
116+ }
117+ noticef(cmd, "%s set in %s", key, path)
118+ return nil
119+ },
120+ }
121+}
122+
123+func newConfigUnsetCmd(env *cliEnv) *cobra.Command {
124+ return &cobra.Command{
125+ Use: "unset <url|token.cmd>",
126+ Short: "Remove a setting from the config file",
127+ Args: cobra.ExactArgs(1),
128+ ValidArgsFunction: completeConfigKeys,
129+ RunE: func(cmd *cobra.Command, args []string) error {
130+ key := args[0]
131+ if err := checkConfigKey(key); err != nil {
132+ return err
133+ }
134+ path, err := env.path()
135+ if err != nil {
136+ return err
137+ }
138+ if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
139+ noticef(cmd, "%s is not set: %s does not exist", key, path)
140+ return nil
141+ }
142+ if err := kvconf.SetFile(path, map[string]*string{key: nil}, 0o600); err != nil {
143+ return fmt.Errorf("writing %s: %w", path, err)
144+ }
145+ noticef(cmd, "%s removed from %s", key, path)
146+ return nil
147+ },
148+ }
149+}
150+
151+func checkConfigKey(key string) error {
152+ for _, k := range configKeys {
153+ if key == k {
154+ return nil
155+ }
156+ }
157+ return fmt.Errorf("unknown key %q: the keys are %s", key, strings.Join(configKeys, " and "))
158+}
159+
160+func completeConfigKeys(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
161+ if len(args) > 0 {
162+ return nil, cobra.ShellCompDirectiveNoFileComp
163+ }
164+ return configKeys, cobra.ShellCompDirectiveNoFileComp
165+}
A internal/app/cli_config_test.go +129 -0
@@ -0,0 +1,129 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "os"
7+ "runtime"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestConfigShowWithoutFile(t *testing.T) {
16+ env := testEnv(t, nil)
17+ path, _ := env.configPath()
18+ res := runCLIWith(t, env, "", "config", "show").ok(t)
19+ assert.Equal(t, "file "+path+"\nurl (not set)\ntoken.cmd (not set)\n", res.stdout)
20+ _, err := os.Stat(path)
21+ assert.ErrorIs(t, err, os.ErrNotExist, "show does not create the file")
22+}
23+
24+func TestConfigSetUnsetShowRoundTrip(t *testing.T) {
25+ env := testEnv(t, nil)
26+ path, _ := env.configPath()
27+ writeConfigFile(t, env, "# my forge\nurl = https://old.example.com\n\n# secrets stay in pass\n", 0o600)
28+
29+ runCLIWith(t, env, "", "config", "set", "url", "https://git.example.com/").ok(t)
30+ runCLIWith(t, env, "", "config", "set", "token.cmd", "pass show ermite/token").ok(t)
31+ // a fresh process sees the new content
32+ res := runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t)
33+ assert.Contains(t, res.stdout, "url https://git.example.com\n")
34+ assert.Contains(t, res.stdout, "token.cmd pass show ermite/token\n")
35+
36+ data, err := os.ReadFile(path)
37+ require.NoError(t, err)
38+ assert.Contains(t, string(data), "# my forge\n")
39+ assert.Contains(t, string(data), "# secrets stay in pass\n")
40+
41+ runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "token.cmd").ok(t)
42+ runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "url").ok(t)
43+ data, err = os.ReadFile(path)
44+ require.NoError(t, err)
45+ assert.NotContains(t, string(data), "token.cmd")
46+ assert.NotContains(t, string(data), "url")
47+ assert.Contains(t, string(data), "# my forge\n", "comments survive")
48+
49+ res = runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t)
50+ assert.Contains(t, res.stdout, "url (not set)\n")
51+ assert.Contains(t, res.stdout, "token.cmd (not set)\n")
52+}
53+
54+// testEnvAt is testEnv with the config file at path: a new process.
55+func testEnvAt(t *testing.T, path string) *cliEnv {
56+ t.Helper()
57+ env := testEnv(t, nil)
58+ env.configPath = func() (string, error) { return path, nil }
59+ return env
60+}
61+
62+func TestConfigSetCreatesPrivateFile(t *testing.T) {
63+ env := testEnv(t, nil)
64+ path, _ := env.configPath()
65+ res := runCLIWith(t, env, "", "config", "set", "token.cmd", "gopass show -o ermite").ok(t)
66+ assert.Contains(t, res.stderr, path)
67+ info, err := os.Stat(path)
68+ require.NoError(t, err)
69+ if runtime.GOOS != "windows" {
70+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
71+ }
72+ // a file created by `config set` is accepted by the permission check
73+ data, _ := os.ReadFile(path)
74+ assert.Equal(t, "token.cmd = gopass show -o ermite\n", string(data))
75+}
76+
77+func TestConfigSetRefusesBadInput(t *testing.T) {
78+ tests := []struct {
79+ name string
80+ args []string
81+ want string
82+ }{
83+ {"unknown key", []string{"set", "token", "x"}, `unknown key "token": the keys are url and token.cmd`},
84+ {"unknown key unset", []string{"unset", "colour"}, `unknown key "colour": the keys are url and token.cmd`},
85+ {"no scheme", []string{"set", "url", "git.example.com"}, "invalid url"},
86+ {"credentials", []string{"set", "url", "https://u:s3cret@git.example.com"}, "must not contain credentials"},
87+ {"ftp", []string{"set", "url", "ftp://git.example.com"}, "invalid url"},
88+ {"empty token.cmd", []string{"set", "token.cmd", " "}, "token.cmd is empty"},
89+ {"multiline token.cmd", []string{"set", "token.cmd", "a\nb"}, "single line"},
90+ {"missing value", []string{"set", "url"}, "accepts 2 arg"},
91+ }
92+ for _, tt := range tests {
93+ t.Run(tt.name, func(t *testing.T) {
94+ env := testEnv(t, nil)
95+ path, _ := env.configPath()
96+ res := runCLIWith(t, env, "", append([]string{"config"}, tt.args...)...)
97+ require.Error(t, res.err)
98+ assert.Contains(t, res.err.Error(), tt.want)
99+ assert.NotContains(t, res.err.Error(), "s3cret")
100+ _, err := os.Stat(path)
101+ assert.ErrorIs(t, err, os.ErrNotExist, "nothing is written")
102+ })
103+ }
104+}
105+
106+func TestConfigUnsetWithoutFile(t *testing.T) {
107+ env := testEnv(t, nil)
108+ path, _ := env.configPath()
109+ res := runCLIWith(t, env, "", "config", "unset", "url").ok(t)
110+ assert.Contains(t, res.stderr, "does not exist")
111+ _, err := os.Stat(path)
112+ assert.ErrorIs(t, err, os.ErrNotExist)
113+}
114+
115+func TestConfigShowNeverRunsTokenCmdAndNotesPrecedence(t *testing.T) {
116+ env := testEnv(t, map[string]string{"ERMITE_URL": "https://env.example.com", "ERMITE_TOKEN": "ermite_x"})
117+ writeConfigFile(t, env, "url = https://file.example.com\ntoken.cmd = get-token\n", 0o666)
118+ res := runCLIWith(t, env, "", "config", "show").ok(t) // testEnv fails the test if token.cmd runs
119+ assert.Contains(t, res.stdout, "url https://file.example.com\n", "show prints what is stored")
120+ assert.Contains(t, res.stderr, "ERMITE_URL is set and takes precedence")
121+ assert.Contains(t, res.stderr, "ERMITE_TOKEN is set: token.cmd is not used")
122+ assert.False(t, strings.Contains(res.stdout+res.stderr, "ermite_x"), "the token is never printed")
123+}
124+
125+func TestConfigHelpShowsAnExample(t *testing.T) {
126+ res := runCLI(t, nil, "", "config", "--help").ok(t)
127+ assert.Contains(t, res.stdout, "pass show ermite/token")
128+ assert.Contains(t, res.stdout, "https://git.example.com")
129+}
@@ -0,0 +1,129 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "os"
7+ "runtime"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestConfigShowWithoutFile(t *testing.T) {
16+ env := testEnv(t, nil)
17+ path, _ := env.configPath()
18+ res := runCLIWith(t, env, "", "config", "show").ok(t)
19+ assert.Equal(t, "file "+path+"\nurl (not set)\ntoken.cmd (not set)\n", res.stdout)
20+ _, err := os.Stat(path)
21+ assert.ErrorIs(t, err, os.ErrNotExist, "show does not create the file")
22+}
23+
24+func TestConfigSetUnsetShowRoundTrip(t *testing.T) {
25+ env := testEnv(t, nil)
26+ path, _ := env.configPath()
27+ writeConfigFile(t, env, "# my forge\nurl = https://old.example.com\n\n# secrets stay in pass\n", 0o600)
28+
29+ runCLIWith(t, env, "", "config", "set", "url", "https://git.example.com/").ok(t)
30+ runCLIWith(t, env, "", "config", "set", "token.cmd", "pass show ermite/token").ok(t)
31+ // a fresh process sees the new content
32+ res := runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t)
33+ assert.Contains(t, res.stdout, "url https://git.example.com\n")
34+ assert.Contains(t, res.stdout, "token.cmd pass show ermite/token\n")
35+
36+ data, err := os.ReadFile(path)
37+ require.NoError(t, err)
38+ assert.Contains(t, string(data), "# my forge\n")
39+ assert.Contains(t, string(data), "# secrets stay in pass\n")
40+
41+ runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "token.cmd").ok(t)
42+ runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "url").ok(t)
43+ data, err = os.ReadFile(path)
44+ require.NoError(t, err)
45+ assert.NotContains(t, string(data), "token.cmd")
46+ assert.NotContains(t, string(data), "url")
47+ assert.Contains(t, string(data), "# my forge\n", "comments survive")
48+
49+ res = runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t)
50+ assert.Contains(t, res.stdout, "url (not set)\n")
51+ assert.Contains(t, res.stdout, "token.cmd (not set)\n")
52+}
53+
54+// testEnvAt is testEnv with the config file at path: a new process.
55+func testEnvAt(t *testing.T, path string) *cliEnv {
56+ t.Helper()
57+ env := testEnv(t, nil)
58+ env.configPath = func() (string, error) { return path, nil }
59+ return env
60+}
61+
62+func TestConfigSetCreatesPrivateFile(t *testing.T) {
63+ env := testEnv(t, nil)
64+ path, _ := env.configPath()
65+ res := runCLIWith(t, env, "", "config", "set", "token.cmd", "gopass show -o ermite").ok(t)
66+ assert.Contains(t, res.stderr, path)
67+ info, err := os.Stat(path)
68+ require.NoError(t, err)
69+ if runtime.GOOS != "windows" {
70+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
71+ }
72+ // a file created by `config set` is accepted by the permission check
73+ data, _ := os.ReadFile(path)
74+ assert.Equal(t, "token.cmd = gopass show -o ermite\n", string(data))
75+}
76+
77+func TestConfigSetRefusesBadInput(t *testing.T) {
78+ tests := []struct {
79+ name string
80+ args []string
81+ want string
82+ }{
83+ {"unknown key", []string{"set", "token", "x"}, `unknown key "token": the keys are url and token.cmd`},
84+ {"unknown key unset", []string{"unset", "colour"}, `unknown key "colour": the keys are url and token.cmd`},
85+ {"no scheme", []string{"set", "url", "git.example.com"}, "invalid url"},
86+ {"credentials", []string{"set", "url", "https://u:s3cret@git.example.com"}, "must not contain credentials"},
87+ {"ftp", []string{"set", "url", "ftp://git.example.com"}, "invalid url"},
88+ {"empty token.cmd", []string{"set", "token.cmd", " "}, "token.cmd is empty"},
89+ {"multiline token.cmd", []string{"set", "token.cmd", "a\nb"}, "single line"},
90+ {"missing value", []string{"set", "url"}, "accepts 2 arg"},
91+ }
92+ for _, tt := range tests {
93+ t.Run(tt.name, func(t *testing.T) {
94+ env := testEnv(t, nil)
95+ path, _ := env.configPath()
96+ res := runCLIWith(t, env, "", append([]string{"config"}, tt.args...)...)
97+ require.Error(t, res.err)
98+ assert.Contains(t, res.err.Error(), tt.want)
99+ assert.NotContains(t, res.err.Error(), "s3cret")
100+ _, err := os.Stat(path)
101+ assert.ErrorIs(t, err, os.ErrNotExist, "nothing is written")
102+ })
103+ }
104+}
105+
106+func TestConfigUnsetWithoutFile(t *testing.T) {
107+ env := testEnv(t, nil)
108+ path, _ := env.configPath()
109+ res := runCLIWith(t, env, "", "config", "unset", "url").ok(t)
110+ assert.Contains(t, res.stderr, "does not exist")
111+ _, err := os.Stat(path)
112+ assert.ErrorIs(t, err, os.ErrNotExist)
113+}
114+
115+func TestConfigShowNeverRunsTokenCmdAndNotesPrecedence(t *testing.T) {
116+ env := testEnv(t, map[string]string{"ERMITE_URL": "https://env.example.com", "ERMITE_TOKEN": "ermite_x"})
117+ writeConfigFile(t, env, "url = https://file.example.com\ntoken.cmd = get-token\n", 0o666)
118+ res := runCLIWith(t, env, "", "config", "show").ok(t) // testEnv fails the test if token.cmd runs
119+ assert.Contains(t, res.stdout, "url https://file.example.com\n", "show prints what is stored")
120+ assert.Contains(t, res.stderr, "ERMITE_URL is set and takes precedence")
121+ assert.Contains(t, res.stderr, "ERMITE_TOKEN is set: token.cmd is not used")
122+ assert.False(t, strings.Contains(res.stdout+res.stderr, "ermite_x"), "the token is never printed")
123+}
124+
125+func TestConfigHelpShowsAnExample(t *testing.T) {
126+ res := runCLI(t, nil, "", "config", "--help").ok(t)
127+ assert.Contains(t, res.stdout, "pass show ermite/token")
128+ assert.Contains(t, res.stdout, "https://git.example.com")
129+}
M internal/app/root.go +1 -1
@@ -26,7 +26,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
2626 root.AddCommand(
2727 newServeCmd(), newHookCmd(),
2828 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
29- newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env),
29+ newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
3030 )
3131 return root
3232 }
@@ -26,7 +26,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
26 root.AddCommand(26 root.AddCommand(
27 newServeCmd(), newHookCmd(),27 newServeCmd(), newHookCmd(),
28 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),28 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
29- newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env),29+ newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
30 )30 )
31 return root31 return root
32 }32 }

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 4

Tags 45

↑↓ moveenter openesc close