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