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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 41 | 41 | | server address | `ERMITE_URL` | `url` | | |
| 42 | 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 | 46 | ```ini | |
| 47 | 47 | # ~/.config/ermite/config | |
| @@ -55,7 +55,7 @@ ermite config set token.cmd 'pass show ermite/token' # or: gopass show -o ermi | |||
| 55 | 55 | 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 | 60 | ```sh | |
| 61 | 61 | 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 | ```ini | 46 | ```ini | ||
| 47 | # ~/.config/ermite/config | 47 | # ~/.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 key | 55 | 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 | ```sh | 60 | ```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) { | |||
| 114 | 114 | return newClient(cmd, cfg, warn) | |
| 115 | 115 | } | |
| 116 | 116 | ||
| 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. | |
| 119 | 119 | func (e *cliEnv) clientNoTokenCmd(cmd *cobra.Command, warn bool) (*client.Client, error) { | |
| 120 | 120 | cfg, err := e.buildConfig(cmd, warn, false) | |
| 121 | 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 | 192 | // complete builds a shell completion: it opens a client (silently giving up | |
| 186 | 193 | // on any failure), lists with a 3 s timeout and keeps the entries whose | |
| 187 | 194 | // first element starts with the prefix. | |
| 188 | 195 | func complete[T any](env *cliEnv, cmd *cobra.Command, prefix string, | |
| 189 | 196 | list func(context.Context, *client.Client) ([]T, error), entry func(T) (key, text string), | |
| 190 | 197 | ) []string { | |
| 191 | - | c, err := env.client(cmd, false) | |
| 198 | + | c, err := env.completionClient(cmd) | |
| 192 | 199 | if err != nil { | |
| 193 | 200 | return nil | |
| 194 | 201 | } | |
| @@ -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, which | 117 | + | // 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 up | 192 | // 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 whose | 193 | // 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 nil | 200 | return nil | ||
| 194 | } | 201 | } | ||
M internal/app/cli_config.go +15 -9
| @@ -26,7 +26,8 @@ func newConfigCmd(env *cliEnv) *cobra.Command { | |||
| 26 | 26 | Use: "config", | |
| 27 | 27 | Short: "Show and edit the config file of the remote commands", | |
| 28 | 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 | 31 | " url = https://git.example.com\n" + | |
| 31 | 32 | " token.cmd = pass show ermite/token\n\n" + | |
| 32 | 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 | 35 | "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" + | |
| 35 | 36 | "take precedence over the file. token.cmd belongs to the server of the file: it is not run when\n" + | |
| 36 | 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 | 41 | cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env)) | |
| 40 | 42 | return cmd | |
| @@ -49,18 +51,22 @@ func newConfigShowCmd(env *cliEnv) *cobra.Command { | |||
| 49 | 51 | Args: cobra.NoArgs, | |
| 50 | 52 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 51 | 53 | f, path, err := env.readFile(cmd, true) | |
| 52 | - | if err != nil { | |
| 53 | - | return err | |
| 54 | - | } | |
| 55 | 54 | 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 | |
| 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 | 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 | 70 | noticef(cmd, "%s is set: token.cmd is not used", client.EnvToken) | |
| 65 | 71 | } | |
| 66 | 72 | 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 cmd | 42 | 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 err | 56 | + | 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) { | |||
| 69 | 69 | if runtime.GOOS != "windows" { | |
| 70 | 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 | 72 | data, _ := os.ReadFile(path) | |
| 74 | 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 | 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 | 124 | func TestConfigHelpDocumentsTheFile(t *testing.T) { | |
| 131 | 125 | res := runCLI(t, nil, "", "--help").ok(t) | |
| 132 | 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 | 130 | assert.Contains(t, res.stdout, "pass show ermite/token") | |
| 137 | 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 | + | } | |
| @@ -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) { | |||
| 184 | 184 | writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600) | |
| 185 | 185 | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 186 | 186 | root := newRootCmdWith(env) | |
| 187 | - | root.SetArgs([]string{"repo", "list"}) | |
| 188 | 187 | for range 2 { | |
| 189 | 188 | c, _, err := root.Find([]string{"repo", "list"}) | |
| 190 | 189 | require.NoError(t, err) | |
| @@ -406,3 +405,32 @@ func TestTokenCmdFollowsASymlinkedConfigFile(t *testing.T) { | |||
| 406 | 405 | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 407 | 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 | + | } | |
| @@ -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 | |||
| 204 | 204 | case 0: | |
| 205 | 205 | return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp | |
| 206 | 206 | case 1: | |
| 207 | - | c, err := env.client(cmd, false) | |
| 207 | + | c, err := env.completionClient(cmd) | |
| 208 | 208 | if err != nil { | |
| 209 | 209 | return nil, cobra.ShellCompDirectiveNoFileComp | |
| 210 | 210 | } | |
| @@ -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.ShellCompDirectiveNoFileComp | 205 | 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.ShellCompDirectiveNoFileComp | 209 | return nil, cobra.ShellCompDirectiveNoFileComp | ||
| 210 | } | 210 | } | ||
M internal/app/cli_version.go +4 -1
| @@ -50,7 +50,10 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||
| 50 | 50 | clientLine := "ermite " + env.version | |
| 51 | 51 | url, err := env.resolveURL(cmd, true) | |
| 52 | 52 | 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 = "" | |
| 54 | 57 | } | |
| 55 | 58 | if url == "" { | |
| 56 | 59 | if *asJSON { | |
| @@ -50,7 +50,10 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||||
| 50 | clientLine := "ermite " + env.version | 50 | 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 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 = "" | |||
| 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 { | |||
| 21 | 21 | "Remote commands need the server address (http:// or https://) and, except for public reads,\n" + | |
| 22 | 22 | "a bearer token (see `ermite token create`). Each comes from, in order:\n\n" + | |
| 23 | 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 | 25 | "token.cmd is a command line that prints the token, run only when a command needs it, so the\n" + | |
| 26 | 26 | "token stays in your secret manager (pass, gopass, op, bw, secret-tool…), never in the file.\n" + | |
| 27 | 27 | "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 @@ | |||
| 1 | 1 | # 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 | 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 | ||
| @@ -1,6 +1,6 @@ | |||||
| 1 | # ermite CLI reference | 1 | # 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 | ||||