detached 3ba41ac

fix(cli): keep completion anonymous and report config file errors

Shell completion never runs token.cmd. ermite version warns about an unreadable config file and carries on without a URL. ermite config show prints the path before the read error. Notices ignore blank variables.
commit
3ba41acba144871f7f2bc653a04a9fc434701734
author
brice · · 2026-10-12 15:13 UTC
parent
aca5d0c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +74 -25
M README.md +2 -2
@@ -41,7 +41,7 @@ Each setting comes from the environment or from a config file, the environment f
4141 | server address | `ERMITE_URL` | `url` |
4242 | bearer token (optional for public reads) | `ERMITE_TOKEN` | `token.cmd`, a command that prints it |
4343
44-The file is `~/.config/ermite/config` (`$XDG_CONFIG_HOME/ermite/config` when set; `ERMITE_CONFIG` names another file). It holds no secret: `token.cmd` is a command line that fetches the token from your secret manager (pass, gopass, op, bw, secret-tool, …).
44+The file is `~/.config/ermite/config` (`$XDG_CONFIG_HOME/ermite/config` when set; `~/Library/Application Support/ermite/config` on macOS and `%AppData%\ermite\config` on Windows; `ERMITE_CONFIG` names another file). `ermite config show` prints the path in use. It holds no secret: `token.cmd` is a command line that fetches the token from your secret manager (pass, gopass, op, bw, secret-tool, …).
4545
4646 ```ini
4747 # ~/.config/ermite/config
@@ -55,7 +55,7 @@ ermite config set token.cmd 'pass show ermite/token' # or: gopass show -o ermi
5555 ermite config show # path and stored values; unset removes a key
5656 ```
5757
58-`token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and terminal (so a pinentry or biometric prompt works). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file is writable by other users. It belongs to the server of the file: when `ERMITE_URL` names a different server than the file's `url`, it is not run and the call is anonymous (with a notice; set `ERMITE_TOKEN` to authenticate there). A failure is reported in one line without its output. A token sent over plain `http://` to a host that is not loopback triggers a warning on every call.
58+`token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and, when standard input is a terminal, that terminal (so a pinentry or biometric prompt works; otherwise it reads nothing). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file, or the directory holding it, can be written by other users or the file belongs to another user. It belongs to the server of the file: when `ERMITE_URL` names a different server than the file's `url`, it is not run and the call is anonymous (with a notice; set `ERMITE_TOKEN` to authenticate there). A failure is reported in one line without its output. Shell completion never runs it: it is anonymous unless `ERMITE_TOKEN` is set.
5959
6060 ```sh
6161 export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...
@@ -41,7 +41,7 @@ Each setting comes from the environment or from a config file, the environment f
41 | server address | `ERMITE_URL` | `url` |41 | server address | `ERMITE_URL` | `url` |
42 | bearer token (optional for public reads) | `ERMITE_TOKEN` | `token.cmd`, a command that prints it |42 | bearer token (optional for public reads) | `ERMITE_TOKEN` | `token.cmd`, a command that prints it |
43 43
44-The file is `~/.config/ermite/config` (`$XDG_CONFIG_HOME/ermite/config` when set; `ERMITE_CONFIG` names another file). It holds no secret: `token.cmd` is a command line that fetches the token from your secret manager (pass, gopass, op, bw, secret-tool, …).44+The file is `~/.config/ermite/config` (`$XDG_CONFIG_HOME/ermite/config` when set; `~/Library/Application Support/ermite/config` on macOS and `%AppData%\ermite\config` on Windows; `ERMITE_CONFIG` names another file). `ermite config show` prints the path in use. It holds no secret: `token.cmd` is a command line that fetches the token from your secret manager (pass, gopass, op, bw, secret-tool, …).
45 45
46 ```ini46 ```ini
47 # ~/.config/ermite/config47 # ~/.config/ermite/config
@@ -55,7 +55,7 @@ ermite config set token.cmd 'pass show ermite/token' # or: gopass show -o ermi
55 ermite config show # path and stored values; unset removes a key55 ermite config show # path and stored values; unset removes a key
56 ```56 ```
57 57
58-`token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and terminal (so a pinentry or biometric prompt works). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file is writable by other users. It belongs to the server of the file: when `ERMITE_URL` names a different server than the file's `url`, it is not run and the call is anonymous (with a notice; set `ERMITE_TOKEN` to authenticate there). A failure is reported in one line without its output. A token sent over plain `http://` to a host that is not loopback triggers a warning on every call.58+`token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and, when standard input is a terminal, that terminal (so a pinentry or biometric prompt works; otherwise it reads nothing). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file, or the directory holding it, can be written by other users or the file belongs to another user. It belongs to the server of the file: when `ERMITE_URL` names a different server than the file's `url`, it is not run and the call is anonymous (with a notice; set `ERMITE_TOKEN` to authenticate there). A failure is reported in one line without its output. Shell completion never runs it: it is anonymous unless `ERMITE_TOKEN` is set.
59 59
60 ```sh60 ```sh
61 export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...61 export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...
M internal/app/cli.go +10 -3
@@ -114,8 +114,8 @@ func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) {
114114 return newClient(cmd, cfg, warn)
115115 }
116116
117-// clientNoTokenCmd is client without running token.cmd, for GetVersion, which
118-// is anonymous.
117+// clientNoTokenCmd is client without running token.cmd (ERMITE_TOKEN is still
118+// used), for GetVersion, which is anonymous, and for completions.
119119 func (e *cliEnv) clientNoTokenCmd(cmd *cobra.Command, warn bool) (*client.Client, error) {
120120 cfg, err := e.buildConfig(cmd, warn, false)
121121 if err != nil {
@@ -182,13 +182,20 @@ func collect[T any](ctx context.Context, fetch func(token string) ([]T, string,
182182 }
183183 }
184184
185+// completionClient is the client of the shell completions: silent, and
186+// anonymous unless ERMITE_TOKEN is set, because completing must never run
187+// token.cmd (it may prompt, and a completion script cannot answer).
188+func (e *cliEnv) completionClient(cmd *cobra.Command) (*client.Client, error) {
189+ return e.clientNoTokenCmd(cmd, false)
190+}
191+
185192 // complete builds a shell completion: it opens a client (silently giving up
186193 // on any failure), lists with a 3 s timeout and keeps the entries whose
187194 // first element starts with the prefix.
188195 func complete[T any](env *cliEnv, cmd *cobra.Command, prefix string,
189196 list func(context.Context, *client.Client) ([]T, error), entry func(T) (key, text string),
190197 ) []string {
191- c, err := env.client(cmd, false)
198+ c, err := env.completionClient(cmd)
192199 if err != nil {
193200 return nil
194201 }
@@ -114,8 +114,8 @@ func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) {
114 return newClient(cmd, cfg, warn)114 return newClient(cmd, cfg, warn)
115 }115 }
116 116
117-// clientNoTokenCmd is client without running token.cmd, for GetVersion, which117+// clientNoTokenCmd is client without running token.cmd (ERMITE_TOKEN is still
118-// is anonymous.118+// used), for GetVersion, which is anonymous, and for completions.
119 func (e *cliEnv) clientNoTokenCmd(cmd *cobra.Command, warn bool) (*client.Client, error) {119 func (e *cliEnv) clientNoTokenCmd(cmd *cobra.Command, warn bool) (*client.Client, error) {
120 cfg, err := e.buildConfig(cmd, warn, false)120 cfg, err := e.buildConfig(cmd, warn, false)
121 if err != nil {121 if err != nil {
@@ -182,13 +182,20 @@ func collect[T any](ctx context.Context, fetch func(token string) ([]T, string,
182 }182 }
183 }183 }
184 184
185+// completionClient is the client of the shell completions: silent, and
186+// anonymous unless ERMITE_TOKEN is set, because completing must never run
187+// token.cmd (it may prompt, and a completion script cannot answer).
188+func (e *cliEnv) completionClient(cmd *cobra.Command) (*client.Client, error) {
189+ return e.clientNoTokenCmd(cmd, false)
190+}
191+
185 // complete builds a shell completion: it opens a client (silently giving up192 // complete builds a shell completion: it opens a client (silently giving up
186 // on any failure), lists with a 3 s timeout and keeps the entries whose193 // on any failure), lists with a 3 s timeout and keeps the entries whose
187 // first element starts with the prefix.194 // first element starts with the prefix.
188 func complete[T any](env *cliEnv, cmd *cobra.Command, prefix string,195 func complete[T any](env *cliEnv, cmd *cobra.Command, prefix string,
189 list func(context.Context, *client.Client) ([]T, error), entry func(T) (key, text string),196 list func(context.Context, *client.Client) ([]T, error), entry func(T) (key, text string),
190 ) []string {197 ) []string {
191- c, err := env.client(cmd, false)198+ c, err := env.completionClient(cmd)
192 if err != nil {199 if err != nil {
193 return nil200 return nil
194 }201 }
M internal/app/cli_config.go +15 -9
@@ -26,7 +26,8 @@ func newConfigCmd(env *cliEnv) *cobra.Command {
2626 Use: "config",
2727 Short: "Show and edit the config file of the remote commands",
2828 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" +
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" +
3031 " url = https://git.example.com\n" +
3132 " token.cmd = pass show ermite/token\n\n" +
3233 "token.cmd is a command line run with the system shell when a command needs the token (at most\n" +
@@ -34,7 +35,8 @@ func newConfigCmd(env *cliEnv) *cobra.Command {
3435 "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" +
3536 "take precedence over the file. token.cmd belongs to the server of the file: it is not run when\n" +
3637 "ERMITE_URL names another server than the url of the file (the call is then anonymous, with a\n" +
37- "notice: set ERMITE_TOKEN). `ermite version` and `ermite config` never run it.",
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).",
3840 }
3941 cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env))
4042 return cmd
@@ -49,18 +51,22 @@ func newConfigShowCmd(env *cliEnv) *cobra.Command {
4951 Args: cobra.NoArgs,
5052 RunE: func(cmd *cobra.Command, _ []string) error {
5153 f, path, err := env.readFile(cmd, true)
52- if err != nil {
53- return err
54- }
5554 if path == "" {
56- if path, err = env.path(); err != nil {
57- return err
55+ p, perr := env.path()
56+ if perr != nil {
57+ return perr
5858 }
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
5965 }
60- if env.getenv(client.EnvURL) != "" && f.URL != "" {
66+ if strings.TrimSpace(env.getenv(client.EnvURL)) != "" && f.URL != "" {
6167 noticef(cmd, "%s is set and takes precedence over url", client.EnvURL)
6268 }
63- if env.getenv(client.EnvToken) != "" && f.TokenCmd != "" {
69+ if strings.TrimSpace(env.getenv(client.EnvToken)) != "" && f.TokenCmd != "" {
6470 noticef(cmd, "%s is set: token.cmd is not used", client.EnvToken)
6571 }
6672 return fields(cmd.OutOrStdout(), [][2]string{
@@ -26,7 +26,8 @@ func newConfigCmd(env *cliEnv) *cobra.Command {
26 Use: "config",26 Use: "config",
27 Short: "Show and edit the config file of the remote commands",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" +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" +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" +
30 " url = https://git.example.com\n" +31 " url = https://git.example.com\n" +
31 " token.cmd = pass show ermite/token\n\n" +32 " 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 "token.cmd is a command line run with the system shell when a command needs the token (at most\n" +
@@ -34,7 +35,8 @@ func newConfigCmd(env *cliEnv) *cobra.Command {
34 "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" +35 "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" +
35 "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" +
36 "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" +
37- "notice: set ERMITE_TOKEN). `ermite version` and `ermite config` never run it.",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).",
38 }40 }
39 cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env))41 cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env))
40 return cmd42 return cmd
@@ -49,18 +51,22 @@ func newConfigShowCmd(env *cliEnv) *cobra.Command {
49 Args: cobra.NoArgs,51 Args: cobra.NoArgs,
50 RunE: func(cmd *cobra.Command, _ []string) error {52 RunE: func(cmd *cobra.Command, _ []string) error {
51 f, path, err := env.readFile(cmd, true)53 f, path, err := env.readFile(cmd, true)
52- if err != nil {
53- return err
54- }
55 if path == "" {54 if path == "" {
56- if path, err = env.path(); err != nil {55+ p, perr := env.path()
57- return err56+ if perr != nil {
57+ return perr
58 }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
59 }65 }
60- if env.getenv(client.EnvURL) != "" && f.URL != "" {66+ if strings.TrimSpace(env.getenv(client.EnvURL)) != "" && f.URL != "" {
61 noticef(cmd, "%s is set and takes precedence over url", client.EnvURL)67 noticef(cmd, "%s is set and takes precedence over url", client.EnvURL)
62 }68 }
63- if env.getenv(client.EnvToken) != "" && f.TokenCmd != "" {69+ if strings.TrimSpace(env.getenv(client.EnvToken)) != "" && f.TokenCmd != "" {
64 noticef(cmd, "%s is set: token.cmd is not used", client.EnvToken)70 noticef(cmd, "%s is set: token.cmd is not used", client.EnvToken)
65 }71 }
66 return fields(cmd.OutOrStdout(), [][2]string{72 return fields(cmd.OutOrStdout(), [][2]string{
M internal/app/cli_config_test.go +11 -6
@@ -69,7 +69,6 @@ func TestConfigSetCreatesPrivateFile(t *testing.T) {
6969 if runtime.GOOS != "windows" {
7070 assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
7171 }
72- // a file created by `config set` is accepted by the permission check
7372 data, _ := os.ReadFile(path)
7473 assert.Equal(t, "token.cmd = gopass show -o ermite\n", string(data))
7574 }
@@ -122,11 +121,6 @@ func TestConfigShowNeverRunsTokenCmdAndNotesPrecedence(t *testing.T) {
122121 assert.False(t, strings.Contains(res.stdout+res.stderr, "ermite_x"), "the token is never printed")
123122 }
124123
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-}
130124 func TestConfigHelpDocumentsTheFile(t *testing.T) {
131125 res := runCLI(t, nil, "", "--help").ok(t)
132126 for _, s := range []string{"ERMITE_URL", "ERMITE_TOKEN", "token.cmd", "ERMITE_CONFIG", "ermite config"} {
@@ -136,3 +130,14 @@ func TestConfigHelpDocumentsTheFile(t *testing.T) {
136130 assert.Contains(t, res.stdout, "pass show ermite/token")
137131 assert.Contains(t, res.stdout, "https://git.example.com")
138132 }
133+
134+func TestConfigShowNamesTheFileItCannotRead(t *testing.T) {
135+ env := testEnv(t, nil)
136+ path := writeConfigFile(t, env, "url = https://git.example.com\n", 0o600)
137+ require.NoError(t, os.Remove(path))
138+ require.NoError(t, os.Mkdir(path, 0o700)) // a directory is not a config file
139+ res := runCLIWith(t, env, "", "config", "show")
140+ require.Error(t, res.err)
141+ assert.Contains(t, res.stdout, path)
142+ assert.Contains(t, res.err.Error(), "config:")
143+}
@@ -69,7 +69,6 @@ func TestConfigSetCreatesPrivateFile(t *testing.T) {
69 if runtime.GOOS != "windows" {69 if runtime.GOOS != "windows" {
70 assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())70 assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
71 }71 }
72- // a file created by `config set` is accepted by the permission check
73 data, _ := os.ReadFile(path)72 data, _ := os.ReadFile(path)
74 assert.Equal(t, "token.cmd = gopass show -o ermite\n", string(data))73 assert.Equal(t, "token.cmd = gopass show -o ermite\n", string(data))
75 }74 }
@@ -122,11 +121,6 @@ func TestConfigShowNeverRunsTokenCmdAndNotesPrecedence(t *testing.T) {
122 assert.False(t, strings.Contains(res.stdout+res.stderr, "ermite_x"), "the token is never printed")121 assert.False(t, strings.Contains(res.stdout+res.stderr, "ermite_x"), "the token is never printed")
123 }122 }
124 123
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-}
130 func TestConfigHelpDocumentsTheFile(t *testing.T) {124 func TestConfigHelpDocumentsTheFile(t *testing.T) {
131 res := runCLI(t, nil, "", "--help").ok(t)125 res := runCLI(t, nil, "", "--help").ok(t)
132 for _, s := range []string{"ERMITE_URL", "ERMITE_TOKEN", "token.cmd", "ERMITE_CONFIG", "ermite config"} {126 for _, s := range []string{"ERMITE_URL", "ERMITE_TOKEN", "token.cmd", "ERMITE_CONFIG", "ermite config"} {
@@ -136,3 +130,14 @@ func TestConfigHelpDocumentsTheFile(t *testing.T) {
136 assert.Contains(t, res.stdout, "pass show ermite/token")130 assert.Contains(t, res.stdout, "pass show ermite/token")
137 assert.Contains(t, res.stdout, "https://git.example.com")131 assert.Contains(t, res.stdout, "https://git.example.com")
138 }132 }
133+
134+func TestConfigShowNamesTheFileItCannotRead(t *testing.T) {
135+ env := testEnv(t, nil)
136+ path := writeConfigFile(t, env, "url = https://git.example.com\n", 0o600)
137+ require.NoError(t, os.Remove(path))
138+ require.NoError(t, os.Mkdir(path, 0o700)) // a directory is not a config file
139+ res := runCLIWith(t, env, "", "config", "show")
140+ require.Error(t, res.err)
141+ assert.Contains(t, res.stdout, path)
142+ assert.Contains(t, res.err.Error(), "config:")
143+}
M internal/app/cli_configfile_test.go +29 -1
@@ -184,7 +184,6 @@ func TestTokenCmdRunsOncePerProcess(t *testing.T) {
184184 writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
185185 ran := tokenCmdStub(env, "ermite_cmd", nil)
186186 root := newRootCmdWith(env)
187- root.SetArgs([]string{"repo", "list"})
188187 for range 2 {
189188 c, _, err := root.Find([]string{"repo", "list"})
190189 require.NoError(t, err)
@@ -406,3 +405,32 @@ func TestTokenCmdFollowsASymlinkedConfigFile(t *testing.T) {
406405 runCLIWith(t, env, "", "repo", "list").ok(t)
407406 assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
408407 }
408+
409+func TestVersionWarnsAboutAnUnreadableConfigFile(t *testing.T) {
410+ env := testEnv(t, nil)
411+ path := writeConfigFile(t, env, "", 0o600)
412+ require.NoError(t, os.Remove(path))
413+ require.NoError(t, os.Mkdir(path, 0o700))
414+ res := runCLIWith(t, env, "", "version").ok(t)
415+ assert.Equal(t, "ermite v0.0.1\n", res.stdout)
416+ assert.Contains(t, res.stderr, "ermite: warning: config:")
417+}
418+
419+func TestCompletionNeverRunsTokenCmd(t *testing.T) {
420+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
421+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
422+ }}
423+ env := testEnv(t, nil)
424+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
425+ ran := tokenCmdStub(env, "ermite_cmd", nil)
426+ res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
427+ assert.Empty(t, *ran)
428+ assert.Contains(t, res.stdout, "alpha")
429+ assert.Empty(t, lastAuth(f))
430+
431+ // with ERMITE_TOKEN set the completion is authenticated
432+ env = testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"})
433+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
434+ runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
435+ assert.Equal(t, "Bearer ermite_env", lastAuth(f))
436+}
@@ -184,7 +184,6 @@ func TestTokenCmdRunsOncePerProcess(t *testing.T) {
184 writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)184 writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
185 ran := tokenCmdStub(env, "ermite_cmd", nil)185 ran := tokenCmdStub(env, "ermite_cmd", nil)
186 root := newRootCmdWith(env)186 root := newRootCmdWith(env)
187- root.SetArgs([]string{"repo", "list"})
188 for range 2 {187 for range 2 {
189 c, _, err := root.Find([]string{"repo", "list"})188 c, _, err := root.Find([]string{"repo", "list"})
190 require.NoError(t, err)189 require.NoError(t, err)
@@ -406,3 +405,32 @@ func TestTokenCmdFollowsASymlinkedConfigFile(t *testing.T) {
406 runCLIWith(t, env, "", "repo", "list").ok(t)405 runCLIWith(t, env, "", "repo", "list").ok(t)
407 assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))406 assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
408 }407 }
408+
409+func TestVersionWarnsAboutAnUnreadableConfigFile(t *testing.T) {
410+ env := testEnv(t, nil)
411+ path := writeConfigFile(t, env, "", 0o600)
412+ require.NoError(t, os.Remove(path))
413+ require.NoError(t, os.Mkdir(path, 0o700))
414+ res := runCLIWith(t, env, "", "version").ok(t)
415+ assert.Equal(t, "ermite v0.0.1\n", res.stdout)
416+ assert.Contains(t, res.stderr, "ermite: warning: config:")
417+}
418+
419+func TestCompletionNeverRunsTokenCmd(t *testing.T) {
420+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
421+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
422+ }}
423+ env := testEnv(t, nil)
424+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
425+ ran := tokenCmdStub(env, "ermite_cmd", nil)
426+ res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
427+ assert.Empty(t, *ran)
428+ assert.Contains(t, res.stdout, "alpha")
429+ assert.Empty(t, lastAuth(f))
430+
431+ // with ERMITE_TOKEN set the completion is authenticated
432+ env = testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"})
433+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
434+ runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
435+ assert.Equal(t, "Bearer ermite_env", lastAuth(f))
436+}
M internal/app/cli_mr.go +1 -1
@@ -204,7 +204,7 @@ func completeMRRepoThenNumber(env *cliEnv) func(*cobra.Command, []string, string
204204 case 0:
205205 return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp
206206 case 1:
207- c, err := env.client(cmd, false)
207+ c, err := env.completionClient(cmd)
208208 if err != nil {
209209 return nil, cobra.ShellCompDirectiveNoFileComp
210210 }
@@ -204,7 +204,7 @@ func completeMRRepoThenNumber(env *cliEnv) func(*cobra.Command, []string, string
204 case 0:204 case 0:
205 return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp205 return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp
206 case 1:206 case 1:
207- c, err := env.client(cmd, false)207+ c, err := env.completionClient(cmd)
208 if err != nil {208 if err != nil {
209 return nil, cobra.ShellCompDirectiveNoFileComp209 return nil, cobra.ShellCompDirectiveNoFileComp
210 }210 }
M internal/app/cli_version.go +4 -1
@@ -50,7 +50,10 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
5050 clientLine := "ermite " + env.version
5151 url, err := env.resolveURL(cmd, true)
5252 if err != nil {
53- return err
53+ // The client version does not depend on the file: say what is wrong
54+ // and go on as if no URL were configured.
55+ warnf(cmd, "%s", err)
56+ url = ""
5457 }
5558 if url == "" {
5659 if *asJSON {
@@ -50,7 +50,10 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
50 clientLine := "ermite " + env.version50 clientLine := "ermite " + env.version
51 url, err := env.resolveURL(cmd, true)51 url, err := env.resolveURL(cmd, true)
52 if err != nil {52 if err != nil {
53- return err53+ // The client version does not depend on the file: say what is wrong
54+ // and go on as if no URL were configured.
55+ warnf(cmd, "%s", err)
56+ url = ""
54 }57 }
55 if url == "" {58 if url == "" {
56 if *asJSON {59 if *asJSON {
M internal/app/root.go +1 -1
@@ -21,7 +21,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
2121 "Remote commands need the server address (http:// or https://) and, except for public reads,\n" +
2222 "a bearer token (see `ermite token create`). Each comes from, in order:\n\n" +
2323 " ERMITE_URL / ERMITE_TOKEN the environment\n" +
24- " url / token.cmd in ~/.config/ermite/config the config file (ERMITE_CONFIG selects another)\n\n" +
24+ " url / token.cmd in ~/.config/ermite/config the config file (ERMITE_CONFIG selects another;\n `ermite config show` prints its path)\n\n" +
2525 "token.cmd is a command line that prints the token, run only when a command needs it, so the\n" +
2626 "token stays in your secret manager (pass, gopass, op, bw, secret-tool…), never in the file.\n" +
2727 "See `ermite config --help`.",
@@ -21,7 +21,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
21 "Remote commands need the server address (http:// or https://) and, except for public reads,\n" +21 "Remote commands need the server address (http:// or https://) and, except for public reads,\n" +
22 "a bearer token (see `ermite token create`). Each comes from, in order:\n\n" +22 "a bearer token (see `ermite token create`). Each comes from, in order:\n\n" +
23 " ERMITE_URL / ERMITE_TOKEN the environment\n" +23 " ERMITE_URL / ERMITE_TOKEN the environment\n" +
24- " url / token.cmd in ~/.config/ermite/config the config file (ERMITE_CONFIG selects another)\n\n" +24+ " url / token.cmd in ~/.config/ermite/config the config file (ERMITE_CONFIG selects another;\n `ermite config show` prints its path)\n\n" +
25 "token.cmd is a command line that prints the token, run only when a command needs it, so the\n" +25 "token.cmd is a command line that prints the token, run only when a command needs it, so the\n" +
26 "token stays in your secret manager (pass, gopass, op, bw, secret-tool…), never in the file.\n" +26 "token stays in your secret manager (pass, gopass, op, bw, secret-tool…), never in the file.\n" +
27 "See `ermite config --help`.",27 "See `ermite config --help`.",
M skills/ermite/CLI.md +1 -1
@@ -1,6 +1,6 @@
11 # ermite CLI reference
22
3-Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads); they fall back to the `url` and `token.cmd` of the config file (`~/.config/ermite/config`, or the file named by `ERMITE_CONFIG`), the environment first (`token.cmd` is only run for the server of the file: with a different `ERMITE_URL` set `ERMITE_TOKEN` too). Agents: use `ERMITE_TOKEN`, or your own `ERMITE_CONFIG` file; do not edit the owner's `~/.config/ermite/config` and never run `token.cmd` yourself or print what it prints. Run `ermite <command> --help` for the authoritative text of a command and its JSON fields.
3+Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads); they fall back to the `url` and `token.cmd` of the config file (`~/.config/ermite/config`, the platform config dir on macOS and Windows, or the file named by `ERMITE_CONFIG`; `ermite config show` prints the path), the environment first (`token.cmd` is only run for the server of the file: with a different `ERMITE_URL` set `ERMITE_TOKEN` too). Agents: use `ERMITE_TOKEN`, or your own `ERMITE_CONFIG` file; do not edit the owner's `~/.config/ermite/config` and never run `token.cmd` yourself or print what it prints. Run `ermite <command> --help` for the authoritative text of a command and its JSON fields.
44
55 Errors are one line on standard error (`ermite: ...`) with exit status 1. `--json` prints stable field names. `ermite completion bash|zsh|fish` completes repository names from the server.
66
@@ -1,6 +1,6 @@
1 # ermite CLI reference1 # ermite CLI reference
2 2
3-Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads); they fall back to the `url` and `token.cmd` of the config file (`~/.config/ermite/config`, or the file named by `ERMITE_CONFIG`), the environment first (`token.cmd` is only run for the server of the file: with a different `ERMITE_URL` set `ERMITE_TOKEN` too). Agents: use `ERMITE_TOKEN`, or your own `ERMITE_CONFIG` file; do not edit the owner's `~/.config/ermite/config` and never run `token.cmd` yourself or print what it prints. Run `ermite <command> --help` for the authoritative text of a command and its JSON fields.3+Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads); they fall back to the `url` and `token.cmd` of the config file (`~/.config/ermite/config`, the platform config dir on macOS and Windows, or the file named by `ERMITE_CONFIG`; `ermite config show` prints the path), the environment first (`token.cmd` is only run for the server of the file: with a different `ERMITE_URL` set `ERMITE_TOKEN` too). Agents: use `ERMITE_TOKEN`, or your own `ERMITE_CONFIG` file; do not edit the owner's `~/.config/ermite/config` and never run `token.cmd` yourself or print what it prints. Run `ermite <command> --help` for the authoritative text of a command and its JSON fields.
4 4
5 Errors are one line on standard error (`ermite: ...`) with exit status 1. `--json` prints stable field names. `ermite completion bash|zsh|fish` completes repository names from the server.5 Errors are one line on standard error (`ermite: ...`) with exit status 1. `--json` prints stable field names. `ermite completion bash|zsh|fish` completes repository names from the server.
6 6

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