detached 9b24d95

Merge pull request 'feat(cli): config file with the server URL and a token command' (#12) from feat/cli-config into main

commit
9b24d958241dabd919b7df587afca537eaeea10a
author
bnema · · 2026-10-12 20:02 UTC
merge of
9b35c25 + c9463fd · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
27 files · +1765 -75
M README.md +25 -1
@@ -30,12 +30,35 @@ ermite serve
3030 | `ERMITE_OWNER` | `owner` | owner login that CI shows in `github.repository` (`<owner>/<repo>`) and `github.repository_owner`; follows the rules of repository names |
3131 | `ERMITE_CI_LOG_RETENTION` | `90d` | how long CI runs, jobs, checks and logs are kept: `Nd` (days) or a Go duration such as `720h`, at least one hour |
3232
33-`ermite version` prints the version (and the server's when `ERMITE_URL` is set).
33+`ermite version` prints the version (and the server's when a server URL is configured, see Remote CLI).
3434
3535 ## Remote CLI
3636
3737 The same binary drives a running server over its API. It reads `ERMITE_URL` (the server address, `http://` or `https://`) and `ERMITE_TOKEN` (a bearer token; optional for public reads). A token sent over plain `http://` to a host that is not loopback triggers a warning on every call.
3838
39+Each setting comes from the environment or from a config file, the environment first:
40+
41+| Setting | Environment | Config file key |
42+|---|---|---|
43+| server address | `ERMITE_URL` | `url` |
44+| bearer token (optional for public reads) | `ERMITE_TOKEN` | `token.cmd`, a command that prints it |
45+
46+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, …).
47+
48+```ini
49+# ~/.config/ermite/config
50+url = https://git.example.com
51+token.cmd = pass show ermite/token
52+```
53+
54+```sh
55+ermite config set url https://git.example.com
56+ermite config set token.cmd 'pass show ermite/token' # or: gopass show -o ermite/token, op read op://vault/ermite/token, secret-tool lookup service ermite
57+ermite config show [--json] # path and stored values; unset removes a key
58+```
59+
60+`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). On a timeout or when ermite is interrupted or terminated, the command and everything it started are killed (when standard input is a terminal, only the shell is, so that prompts keep working). 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 (`ermite config show` and `set` warn about it). On Windows this check does not exist: keep the file in your own profile, the default place. 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.
61+
3962 ```sh
4063 export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...
4164 ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes]
@@ -50,6 +73,7 @@ ermite runner list | remove <id>
5073 ermite web login # prints a one-time sign-in link (60 s)
5174 ermite web logout --all # signs every browser out
5275 ermite signing-key show # public key and allowed_signers line of squash commits
76+ermite config show [--json] | set <url|token.cmd> <value> | unset <key> # the CLI config file
5377 ermite version # client and server, warns about missing server features
5478 ermite watch [repo] [--json] # follows events, reconnects and resumes
5579 ```
@@ -30,12 +30,35 @@ ermite serve
30 | `ERMITE_OWNER` | `owner` | owner login that CI shows in `github.repository` (`<owner>/<repo>`) and `github.repository_owner`; follows the rules of repository names |30 | `ERMITE_OWNER` | `owner` | owner login that CI shows in `github.repository` (`<owner>/<repo>`) and `github.repository_owner`; follows the rules of repository names |
31 | `ERMITE_CI_LOG_RETENTION` | `90d` | how long CI runs, jobs, checks and logs are kept: `Nd` (days) or a Go duration such as `720h`, at least one hour |31 | `ERMITE_CI_LOG_RETENTION` | `90d` | how long CI runs, jobs, checks and logs are kept: `Nd` (days) or a Go duration such as `720h`, at least one hour |
32 32
33-`ermite version` prints the version (and the server's when `ERMITE_URL` is set).33+`ermite version` prints the version (and the server's when a server URL is configured, see Remote CLI).
34 34
35 ## Remote CLI35 ## Remote CLI
36 36
37 The same binary drives a running server over its API. It reads `ERMITE_URL` (the server address, `http://` or `https://`) and `ERMITE_TOKEN` (a bearer token; optional for public reads). A token sent over plain `http://` to a host that is not loopback triggers a warning on every call.37 The same binary drives a running server over its API. It reads `ERMITE_URL` (the server address, `http://` or `https://`) and `ERMITE_TOKEN` (a bearer token; optional for public reads). A token sent over plain `http://` to a host that is not loopback triggers a warning on every call.
38 38
39+Each setting comes from the environment or from a config file, the environment first:
40+
41+| Setting | Environment | Config file key |
42+|---|---|---|
43+| server address | `ERMITE_URL` | `url` |
44+| bearer token (optional for public reads) | `ERMITE_TOKEN` | `token.cmd`, a command that prints it |
45+
46+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, …).
47+
48+```ini
49+# ~/.config/ermite/config
50+url = https://git.example.com
51+token.cmd = pass show ermite/token
52+```
53+
54+```sh
55+ermite config set url https://git.example.com
56+ermite config set token.cmd 'pass show ermite/token' # or: gopass show -o ermite/token, op read op://vault/ermite/token, secret-tool lookup service ermite
57+ermite config show [--json] # path and stored values; unset removes a key
58+```
59+
60+`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). On a timeout or when ermite is interrupted or terminated, the command and everything it started are killed (when standard input is a terminal, only the shell is, so that prompts keep working). 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 (`ermite config show` and `set` warn about it). On Windows this check does not exist: keep the file in your own profile, the default place. 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.
61+
39 ```sh62 ```sh
40 export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...63 export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...
41 ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes]64 ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes]
@@ -50,6 +73,7 @@ ermite runner list | remove <id>
50 ermite web login # prints a one-time sign-in link (60 s)73 ermite web login # prints a one-time sign-in link (60 s)
51 ermite web logout --all # signs every browser out74 ermite web logout --all # signs every browser out
52 ermite signing-key show # public key and allowed_signers line of squash commits75 ermite signing-key show # public key and allowed_signers line of squash commits
76+ermite config show [--json] | set <url|token.cmd> <value> | unset <key> # the CLI config file
53 ermite version # client and server, warns about missing server features77 ermite version # client and server, warns about missing server features
54 ermite watch [repo] [--json] # follows events, reconnects and resumes78 ermite watch [repo] [--json] # follows events, reconnects and resumes
55 ```79 ```
M go.mod +2 -1
@@ -19,6 +19,7 @@ require (
1919 connectrpc.com/connect v1.21.0
2020 github.com/a-h/templ v0.3.1020
2121 github.com/alecthomas/chroma/v2 v2.27.0
22+ github.com/bnema/kvconf v0.1.2
2223 github.com/bnema/zerowrap v1.4.1
2324 github.com/golang-jwt/jwt/v5 v5.3.1
2425 github.com/spf13/cobra v1.10.2
@@ -26,6 +27,7 @@ require (
2627 github.com/yuin/goldmark v1.8.6
2728 golang.org/x/crypto v0.57.0
2829 golang.org/x/sys v0.49.0
30+ golang.org/x/term v0.46.0
2931 google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4
3032 google.golang.org/protobuf v1.36.12
3133 )
@@ -152,7 +154,6 @@ require (
152154 golang.org/x/mod v0.41.0 // indirect
153155 golang.org/x/net v0.59.0 // indirect
154156 golang.org/x/sync v0.23.0 // indirect
155- golang.org/x/term v0.46.0 // indirect
156157 golang.org/x/text v0.42.0 // indirect
157158 golang.org/x/tools v0.51.0 // indirect
158159 google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect
@@ -19,6 +19,7 @@ require (
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
20 github.com/a-h/templ v0.3.102020 github.com/a-h/templ v0.3.1020
21 github.com/alecthomas/chroma/v2 v2.27.021 github.com/alecthomas/chroma/v2 v2.27.0
22+ github.com/bnema/kvconf v0.1.2
22 github.com/bnema/zerowrap v1.4.123 github.com/bnema/zerowrap v1.4.1
23 github.com/golang-jwt/jwt/v5 v5.3.124 github.com/golang-jwt/jwt/v5 v5.3.1
24 github.com/spf13/cobra v1.10.225 github.com/spf13/cobra v1.10.2
@@ -26,6 +27,7 @@ require (
26 github.com/yuin/goldmark v1.8.627 github.com/yuin/goldmark v1.8.6
27 golang.org/x/crypto v0.57.028 golang.org/x/crypto v0.57.0
28 golang.org/x/sys v0.49.029 golang.org/x/sys v0.49.0
30+ golang.org/x/term v0.46.0
29 google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a431 google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4
30 google.golang.org/protobuf v1.36.1232 google.golang.org/protobuf v1.36.12
31 )33 )
@@ -152,7 +154,6 @@ require (
152 golang.org/x/mod v0.41.0 // indirect154 golang.org/x/mod v0.41.0 // indirect
153 golang.org/x/net v0.59.0 // indirect155 golang.org/x/net v0.59.0 // indirect
154 golang.org/x/sync v0.23.0 // indirect156 golang.org/x/sync v0.23.0 // indirect
155- golang.org/x/term v0.46.0 // indirect
156 golang.org/x/text v0.42.0 // indirect157 golang.org/x/text v0.42.0 // indirect
157 golang.org/x/tools v0.51.0 // indirect158 golang.org/x/tools v0.51.0 // indirect
158 google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect159 google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect
M go.sum +2 -0
@@ -108,6 +108,8 @@ github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6
108108 github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
109109 github.com/bnema/hexcheck v0.3.1 h1:Apv7w0KmsfbOBOq/r1jPlENX3Z7ukAEPlAxvWMTTd3I=
110110 github.com/bnema/hexcheck v0.3.1/go.mod h1:8KuFStFhn1qNKDBrUvxOB5VzSP/uuePrURqPhq2KVAk=
111+github.com/bnema/kvconf v0.1.2 h1:1hDKL9jkQSBdR9dSKbwTONhUUBfJgAdh5vu+yFHOb70=
112+github.com/bnema/kvconf v0.1.2/go.mod h1:+P2X9I/sLx+Lfir45s9a7WA0GnC22WgKbZWE9LK6lcU=
111113 github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU=
112114 github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg=
113115 github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
@@ -108,6 +108,8 @@ github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6
108 github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=108 github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
109 github.com/bnema/hexcheck v0.3.1 h1:Apv7w0KmsfbOBOq/r1jPlENX3Z7ukAEPlAxvWMTTd3I=109 github.com/bnema/hexcheck v0.3.1 h1:Apv7w0KmsfbOBOq/r1jPlENX3Z7ukAEPlAxvWMTTd3I=
110 github.com/bnema/hexcheck v0.3.1/go.mod h1:8KuFStFhn1qNKDBrUvxOB5VzSP/uuePrURqPhq2KVAk=110 github.com/bnema/hexcheck v0.3.1/go.mod h1:8KuFStFhn1qNKDBrUvxOB5VzSP/uuePrURqPhq2KVAk=
111+github.com/bnema/kvconf v0.1.2 h1:1hDKL9jkQSBdR9dSKbwTONhUUBfJgAdh5vu+yFHOb70=
112+github.com/bnema/kvconf v0.1.2/go.mod h1:+P2X9I/sLx+Lfir45s9a7WA0GnC22WgKbZWE9LK6lcU=
111 github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU=113 github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU=
112 github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg=114 github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg=
113 github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=115 github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
M internal/adapters/client/config.go +8 -7
@@ -48,18 +48,19 @@ type Config struct {
4848 UserAgent string
4949 }
5050
51-// ConfigFromEnv reads ERMITE_URL and ERMITE_TOKEN through getenv and validates
52-// them. Timeout and UserAgent are left for the caller to set.
53-func ConfigFromEnv(getenv func(string) string) (Config, error) {
54- raw := strings.TrimSpace(getenv(EnvURL))
51+// NewConfig validates the server address and the token, however they were
52+// found (environment, config file or token command). The token is trimmed and
53+// may be empty (anonymous). Timeout and UserAgent are left for the caller.
54+func NewConfig(rawURL, token string) (Config, error) {
55+ raw := strings.TrimSpace(rawURL)
5556 if raw == "" {
56- return Config{}, fmt.Errorf("%s is not set: set it to the address of your ermite server, for example https://git.example.com", EnvURL)
57+ return Config{}, fmt.Errorf("%s is not set: set it to the address of your ermite server, for example https://git.example.com, or run `ermite config set url <url>`", EnvURL)
5758 }
5859 base, err := ParseURL(raw)
5960 if err != nil {
60- return Config{}, fmt.Errorf("%s: %w", EnvURL, err)
61+ return Config{}, fmt.Errorf("invalid server URL: %w", err)
6162 }
62- return Config{URL: base.String(), Token: strings.TrimSpace(getenv(EnvToken))}, nil
63+ return Config{URL: base.String(), Token: strings.TrimSpace(token)}, nil
6364 }
6465
6566 // ParseURL validates a server address: an absolute http(s) URL with a host,
@@ -48,18 +48,19 @@ type Config struct {
48 UserAgent string48 UserAgent string
49 }49 }
50 50
51-// ConfigFromEnv reads ERMITE_URL and ERMITE_TOKEN through getenv and validates51+// NewConfig validates the server address and the token, however they were
52-// them. Timeout and UserAgent are left for the caller to set.52+// found (environment, config file or token command). The token is trimmed and
53-func ConfigFromEnv(getenv func(string) string) (Config, error) {53+// may be empty (anonymous). Timeout and UserAgent are left for the caller.
54- raw := strings.TrimSpace(getenv(EnvURL))54+func NewConfig(rawURL, token string) (Config, error) {
55+ raw := strings.TrimSpace(rawURL)
55 if raw == "" {56 if raw == "" {
56- return Config{}, fmt.Errorf("%s is not set: set it to the address of your ermite server, for example https://git.example.com", EnvURL)57+ return Config{}, fmt.Errorf("%s is not set: set it to the address of your ermite server, for example https://git.example.com, or run `ermite config set url <url>`", EnvURL)
57 }58 }
58 base, err := ParseURL(raw)59 base, err := ParseURL(raw)
59 if err != nil {60 if err != nil {
60- return Config{}, fmt.Errorf("%s: %w", EnvURL, err)61+ return Config{}, fmt.Errorf("invalid server URL: %w", err)
61 }62 }
62- return Config{URL: base.String(), Token: strings.TrimSpace(getenv(EnvToken))}, nil63+ return Config{URL: base.String(), Token: strings.TrimSpace(token)}, nil
63 }64 }
64 65
65 // ParseURL validates a server address: an absolute http(s) URL with a host,66 // ParseURL validates a server address: an absolute http(s) URL with a host,
M internal/adapters/client/config_test.go +11 -10
@@ -11,20 +11,21 @@ import (
1111 "bnema.dev/ermite/internal/adapters/client"
1212 )
1313
14-func env(kv map[string]string) func(string) string {
15- return func(k string) string { return kv[k] }
16-}
17-
18-func TestConfigFromEnv(t *testing.T) {
19- cfg, err := client.ConfigFromEnv(env(map[string]string{
20- client.EnvURL: " HTTPS://Git.Example.com/base/ ", client.EnvToken: " ermite_abc\n",
21- }))
14+func TestNewConfig(t *testing.T) {
15+ cfg, err := client.NewConfig(" HTTPS://Git.Example.com/base/ ", " ermite_abc\n")
2216 require.NoError(t, err)
2317 assert.Equal(t, "https://Git.Example.com/base", cfg.URL)
2418 assert.Equal(t, "ermite_abc", cfg.Token)
2519 }
2620
27-func TestConfigFromEnvRejects(t *testing.T) {
21+func TestNewConfigUnsetTellsHowToFixIt(t *testing.T) {
22+ _, err := client.NewConfig(" ", "")
23+ require.Error(t, err)
24+ assert.Contains(t, err.Error(), "ERMITE_URL")
25+ assert.Contains(t, err.Error(), "ermite config set url")
26+}
27+
28+func TestNewConfigRejects(t *testing.T) {
2829 tests := []struct {
2930 name, url, want string
3031 }{
@@ -39,7 +40,7 @@ func TestConfigFromEnvRejects(t *testing.T) {
3940 }
4041 for _, tt := range tests {
4142 t.Run(tt.name, func(t *testing.T) {
42- _, err := client.ConfigFromEnv(env(map[string]string{client.EnvURL: tt.url}))
43+ _, err := client.NewConfig(tt.url, "")
4344 require.Error(t, err)
4445 assert.Contains(t, err.Error(), tt.want)
4546 assert.NotContains(t, err.Error(), "s3cret", "credentials must never be echoed")
@@ -11,20 +11,21 @@ import (
11 "bnema.dev/ermite/internal/adapters/client"11 "bnema.dev/ermite/internal/adapters/client"
12 )12 )
13 13
14-func env(kv map[string]string) func(string) string {14+func TestNewConfig(t *testing.T) {
15- return func(k string) string { return kv[k] }15+ cfg, err := client.NewConfig(" HTTPS://Git.Example.com/base/ ", " ermite_abc\n")
16-}
17-
18-func TestConfigFromEnv(t *testing.T) {
19- cfg, err := client.ConfigFromEnv(env(map[string]string{
20- client.EnvURL: " HTTPS://Git.Example.com/base/ ", client.EnvToken: " ermite_abc\n",
21- }))
22 require.NoError(t, err)16 require.NoError(t, err)
23 assert.Equal(t, "https://Git.Example.com/base", cfg.URL)17 assert.Equal(t, "https://Git.Example.com/base", cfg.URL)
24 assert.Equal(t, "ermite_abc", cfg.Token)18 assert.Equal(t, "ermite_abc", cfg.Token)
25 }19 }
26 20
27-func TestConfigFromEnvRejects(t *testing.T) {21+func TestNewConfigUnsetTellsHowToFixIt(t *testing.T) {
22+ _, err := client.NewConfig(" ", "")
23+ require.Error(t, err)
24+ assert.Contains(t, err.Error(), "ERMITE_URL")
25+ assert.Contains(t, err.Error(), "ermite config set url")
26+}
27+
28+func TestNewConfigRejects(t *testing.T) {
28 tests := []struct {29 tests := []struct {
29 name, url, want string30 name, url, want string
30 }{31 }{
@@ -39,7 +40,7 @@ func TestConfigFromEnvRejects(t *testing.T) {
39 }40 }
40 for _, tt := range tests {41 for _, tt := range tests {
41 t.Run(tt.name, func(t *testing.T) {42 t.Run(tt.name, func(t *testing.T) {
42- _, err := client.ConfigFromEnv(env(map[string]string{client.EnvURL: tt.url}))43+ _, err := client.NewConfig(tt.url, "")
43 require.Error(t, err)44 require.Error(t, err)
44 assert.Contains(t, err.Error(), tt.want)45 assert.Contains(t, err.Error(), tt.want)
45 assert.NotContains(t, err.Error(), "s3cret", "credentials must never be echoed")46 assert.NotContains(t, err.Error(), "s3cret", "credentials must never be echoed")
M internal/app/cli.go +69 -10
@@ -9,6 +9,7 @@ import (
99 "io"
1010 "os"
1111 "strings"
12+ "sync"
1213 "text/tabwriter"
1314 "time"
1415
@@ -30,10 +31,28 @@ type cliEnv struct {
3031 // defaults.
3132 backoff client.Backoff
3233 sleep func(context.Context, time.Duration) error
34+
35+ // configPath returns the CLI config file path; nil means the default
36+ // (ERMITE_CONFIG, else ~/.config/ermite/config).
37+ configPath func() (string, error)
38+ // runTokenCmd runs the token.cmd command line and returns its raw standard
39+ // output. Tests replace it.
40+ runTokenCmd func(ctx context.Context, cmd string) (string, error)
41+
42+ // The config file is read at most once and token.cmd run at most once per
43+ // process, and only when a command needs them (see cli_configfile.go).
44+ fileOnce sync.Once
45+ file *cliFile
46+ filePath string
47+ fileTrust error // why token.cmd must not run from this file, if so
48+ fileErr error
49+ tokenOnce sync.Once
50+ token string
51+ tokenErr error
3352 }
3453
3554 func defaultCLIEnv() *cliEnv {
36- return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion()}
55+ return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion(), runTokenCmd: runTokenShell}
3756 }
3857
3958 // userAgent identifies this CLI to the server.
@@ -54,23 +73,56 @@ func (e *cliEnv) ctx(cmd *cobra.Command) context.Context {
5473 return ctx
5574 }
5675
57-// config reads and validates ERMITE_URL and ERMITE_TOKEN.
58-func (e *cliEnv) config() (client.Config, error) {
59- cfg, err := client.ConfigFromEnv(e.getenv)
76+// buildConfig builds the client configuration: ERMITE_URL and ERMITE_TOKEN
77+// win, then the config file (url, and the output of token.cmd for the token).
78+// runTokenCmd false leaves token.cmd alone (ERMITE_TOKEN is still used), for
79+// the calls that need no authentication.
80+func (e *cliEnv) buildConfig(cmd *cobra.Command, warn, runTokenCmd bool) (client.Config, error) {
81+ rawURL, err := e.resolveURL(cmd, warn)
6082 if err != nil {
61- return cfg, err
83+ return client.Config{}, err
84+ }
85+ if strings.TrimSpace(rawURL) != "" {
86+ if _, err := client.ParseURL(strings.TrimSpace(rawURL)); err != nil {
87+ return client.Config{}, fmt.Errorf("%s: %w", e.urlSource(), err)
88+ }
89+ }
90+ // A missing or invalid URL is reported before token.cmd gets to prompt.
91+ cfg, err := client.NewConfig(rawURL, "")
92+ if err != nil {
93+ return client.Config{}, err
94+ }
95+ cfg.Token = strings.TrimSpace(e.getenv(client.EnvToken))
96+ if cfg.Token == "" && runTokenCmd {
97+ if cfg.Token, err = e.tokenFromCmd(cmd, warn); err != nil {
98+ return client.Config{}, err
99+ }
62100 }
63101 cfg.UserAgent = e.userAgent()
64102 return cfg, nil
65103 }
66104
67-// client builds the API client. Commands warn when the token would travel in
68-// the clear; completion stays silent.
69-func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) {
70- cfg, err := e.config()
105+// client builds the API client. It warns when the token would travel in the
106+// clear.
107+func (e *cliEnv) client(cmd *cobra.Command) (*client.Client, error) {
108+ cfg, err := e.buildConfig(cmd, true, true)
71109 if err != nil {
72110 return nil, err
73111 }
112+ return newClient(cmd, cfg, true)
113+}
114+
115+// clientNoTokenCmd is client without running token.cmd (ERMITE_TOKEN is still
116+// used), for GetVersion, which is anonymous, and for completions.
117+func (e *cliEnv) clientNoTokenCmd(cmd *cobra.Command, warn bool) (*client.Client, error) {
118+ cfg, err := e.buildConfig(cmd, warn, false)
119+ if err != nil {
120+ return nil, err
121+ }
122+ return newClient(cmd, cfg, warn)
123+}
124+
125+func newClient(cmd *cobra.Command, cfg client.Config, warn bool) (*client.Client, error) {
74126 if w := cfg.InsecureTokenWarning(); w != "" && warn {
75127 warnf(cmd, "%s", w)
76128 }
@@ -128,13 +180,20 @@ func collect[T any](ctx context.Context, fetch func(token string) ([]T, string,
128180 }
129181 }
130182
183+// completionClient is the client of the shell completions: silent, and
184+// anonymous unless ERMITE_TOKEN is set, because completing must never run
185+// token.cmd (it may prompt, and a completion script cannot answer).
186+func (e *cliEnv) completionClient(cmd *cobra.Command) (*client.Client, error) {
187+ return e.clientNoTokenCmd(cmd, false)
188+}
189+
131190 // complete builds a shell completion: it opens a client (silently giving up
132191 // on any failure), lists with a 3 s timeout and keeps the entries whose
133192 // first element starts with the prefix.
134193 func complete[T any](env *cliEnv, cmd *cobra.Command, prefix string,
135194 list func(context.Context, *client.Client) ([]T, error), entry func(T) (key, text string),
136195 ) []string {
137- c, err := env.client(cmd, false)
196+ c, err := env.completionClient(cmd)
138197 if err != nil {
139198 return nil
140199 }
@@ -9,6 +9,7 @@ import (
9 "io"9 "io"
10 "os"10 "os"
11 "strings"11 "strings"
12+ "sync"
12 "text/tabwriter"13 "text/tabwriter"
13 "time"14 "time"
14 15
@@ -30,10 +31,28 @@ type cliEnv struct {
30 // defaults.31 // defaults.
31 backoff client.Backoff32 backoff client.Backoff
32 sleep func(context.Context, time.Duration) error33 sleep func(context.Context, time.Duration) error
34+
35+ // configPath returns the CLI config file path; nil means the default
36+ // (ERMITE_CONFIG, else ~/.config/ermite/config).
37+ configPath func() (string, error)
38+ // runTokenCmd runs the token.cmd command line and returns its raw standard
39+ // output. Tests replace it.
40+ runTokenCmd func(ctx context.Context, cmd string) (string, error)
41+
42+ // The config file is read at most once and token.cmd run at most once per
43+ // process, and only when a command needs them (see cli_configfile.go).
44+ fileOnce sync.Once
45+ file *cliFile
46+ filePath string
47+ fileTrust error // why token.cmd must not run from this file, if so
48+ fileErr error
49+ tokenOnce sync.Once
50+ token string
51+ tokenErr error
33 }52 }
34 53
35 func defaultCLIEnv() *cliEnv {54 func defaultCLIEnv() *cliEnv {
36- return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion()}55+ return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion(), runTokenCmd: runTokenShell}
37 }56 }
38 57
39 // userAgent identifies this CLI to the server.58 // userAgent identifies this CLI to the server.
@@ -54,23 +73,56 @@ func (e *cliEnv) ctx(cmd *cobra.Command) context.Context {
54 return ctx73 return ctx
55 }74 }
56 75
57-// config reads and validates ERMITE_URL and ERMITE_TOKEN.76+// buildConfig builds the client configuration: ERMITE_URL and ERMITE_TOKEN
58-func (e *cliEnv) config() (client.Config, error) {77+// win, then the config file (url, and the output of token.cmd for the token).
59- cfg, err := client.ConfigFromEnv(e.getenv)78+// runTokenCmd false leaves token.cmd alone (ERMITE_TOKEN is still used), for
79+// the calls that need no authentication.
80+func (e *cliEnv) buildConfig(cmd *cobra.Command, warn, runTokenCmd bool) (client.Config, error) {
81+ rawURL, err := e.resolveURL(cmd, warn)
60 if err != nil {82 if err != nil {
61- return cfg, err83+ return client.Config{}, err
84+ }
85+ if strings.TrimSpace(rawURL) != "" {
86+ if _, err := client.ParseURL(strings.TrimSpace(rawURL)); err != nil {
87+ return client.Config{}, fmt.Errorf("%s: %w", e.urlSource(), err)
88+ }
89+ }
90+ // A missing or invalid URL is reported before token.cmd gets to prompt.
91+ cfg, err := client.NewConfig(rawURL, "")
92+ if err != nil {
93+ return client.Config{}, err
94+ }
95+ cfg.Token = strings.TrimSpace(e.getenv(client.EnvToken))
96+ if cfg.Token == "" && runTokenCmd {
97+ if cfg.Token, err = e.tokenFromCmd(cmd, warn); err != nil {
98+ return client.Config{}, err
99+ }
62 }100 }
63 cfg.UserAgent = e.userAgent()101 cfg.UserAgent = e.userAgent()
64 return cfg, nil102 return cfg, nil
65 }103 }
66 104
67-// client builds the API client. Commands warn when the token would travel in105+// client builds the API client. It warns when the token would travel in the
68-// the clear; completion stays silent.106+// clear.
69-func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) {107+func (e *cliEnv) client(cmd *cobra.Command) (*client.Client, error) {
70- cfg, err := e.config()108+ cfg, err := e.buildConfig(cmd, true, true)
71 if err != nil {109 if err != nil {
72 return nil, err110 return nil, err
73 }111 }
112+ return newClient(cmd, cfg, true)
113+}
114+
115+// clientNoTokenCmd is client without running token.cmd (ERMITE_TOKEN is still
116+// used), for GetVersion, which is anonymous, and for completions.
117+func (e *cliEnv) clientNoTokenCmd(cmd *cobra.Command, warn bool) (*client.Client, error) {
118+ cfg, err := e.buildConfig(cmd, warn, false)
119+ if err != nil {
120+ return nil, err
121+ }
122+ return newClient(cmd, cfg, warn)
123+}
124+
125+func newClient(cmd *cobra.Command, cfg client.Config, warn bool) (*client.Client, error) {
74 if w := cfg.InsecureTokenWarning(); w != "" && warn {126 if w := cfg.InsecureTokenWarning(); w != "" && warn {
75 warnf(cmd, "%s", w)127 warnf(cmd, "%s", w)
76 }128 }
@@ -128,13 +180,20 @@ func collect[T any](ctx context.Context, fetch func(token string) ([]T, string,
128 }180 }
129 }181 }
130 182
183+// completionClient is the client of the shell completions: silent, and
184+// anonymous unless ERMITE_TOKEN is set, because completing must never run
185+// token.cmd (it may prompt, and a completion script cannot answer).
186+func (e *cliEnv) completionClient(cmd *cobra.Command) (*client.Client, error) {
187+ return e.clientNoTokenCmd(cmd, false)
188+}
189+
131 // complete builds a shell completion: it opens a client (silently giving up190 // complete builds a shell completion: it opens a client (silently giving up
132 // on any failure), lists with a 3 s timeout and keeps the entries whose191 // on any failure), lists with a 3 s timeout and keeps the entries whose
133 // first element starts with the prefix.192 // first element starts with the prefix.
134 func complete[T any](env *cliEnv, cmd *cobra.Command, prefix string,193 func complete[T any](env *cliEnv, cmd *cobra.Command, prefix string,
135 list func(context.Context, *client.Client) ([]T, error), entry func(T) (key, text string),194 list func(context.Context, *client.Client) ([]T, error), entry func(T) (key, text string),
136 ) []string {195 ) []string {
137- c, err := env.client(cmd, false)196+ c, err := env.completionClient(cmd)
138 if err != nil {197 if err != nil {
139 return nil198 return nil
140 }199 }
A internal/app/cli_config.go +214 -0
@@ -0,0 +1,214 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "io/fs"
9+ "slices"
10+ "strings"
11+
12+ "github.com/bnema/kvconf"
13+ "github.com/spf13/cobra"
14+
15+ "bnema.dev/ermite/internal/adapters/client"
16+)
17+
18+// notSet is what `config show` prints for a key without a value.
19+const notSet = "(not set)"
20+
21+// configKeys are the keys of the CLI config file.
22+var configKeys = []string{keyURL, keyTokenCmd}
23+
24+func newConfigCmd(env *cliEnv) *cobra.Command {
25+ cmd := &cobra.Command{
26+ Use: "config",
27+ Short: "Show and edit the config file of the remote commands",
28+ Long: "The remote commands read the server address and a way to get a token from a config file,\n" +
29+ "~/.config/ermite/config (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" +
31+ " url = https://git.example.com\n" +
32+ " token.cmd = pass show ermite/token\n\n" +
33+ "token.cmd is a command line run with the system shell when a command needs the token (at most\n" +
34+ "once per run): it must print the token alone on one line. Use any secret manager (pass, gopass,\n" +
35+ "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\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" +
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).\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).",
43+ }
44+ cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env))
45+ return cmd
46+}
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+
55+func newConfigShowCmd(env *cliEnv) *cobra.Command {
56+ cmd := &cobra.Command{
57+ Use: "show",
58+ Short: "Print the config file path and its settings",
59+ Long: "Print the path of the config file, then the url and token.cmd stored in it ((not set) when\n" +
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.",
62+ Args: cobra.NoArgs,
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+ }
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
103+ }
104+ return &s
105+}
106+
107+func orNotSet(s string) string {
108+ if strings.TrimSpace(s) == "" {
109+ return notSet
110+ }
111+ return s
112+}
113+
114+func newConfigSetCmd(env *cliEnv) *cobra.Command {
115+ return &cobra.Command{
116+ Use: "set <url|token.cmd> <value>",
117+ Short: "Set the server address or the token command in the config file",
118+ Long: "Write a setting to the config file, keeping its comments, and create the file (mode 0600) if needed.\n" +
119+ "url is checked: an http(s) address without credentials. token.cmd is a command line that prints the\n" +
120+ "token, for example `pass show ermite/token`; it is stored as is and not run.",
121+ Args: cobra.ExactArgs(2),
122+ ValidArgsFunction: completeConfigKeys,
123+ RunE: func(cmd *cobra.Command, args []string) error {
124+ key, value := args[0], strings.TrimSpace(args[1])
125+ if err := checkConfigKey(key); err != nil {
126+ return err
127+ }
128+ switch key {
129+ case keyURL:
130+ u, err := client.ParseURL(value)
131+ if err != nil {
132+ return fmt.Errorf("invalid url: %w", err)
133+ }
134+ value = u.String()
135+ case keyTokenCmd:
136+ if value == "" {
137+ return errors.New("token.cmd is empty: use `ermite config unset token.cmd` to remove it")
138+ }
139+ if strings.ContainsAny(value, "\r\n") {
140+ return errors.New("token.cmd must be a single line")
141+ }
142+ }
143+ path, err := env.path()
144+ if err != nil {
145+ return err
146+ }
147+ if err := kvconf.SetFile(path, map[string]*string{key: &value}, 0o600); err != nil {
148+ return fmt.Errorf("writing %s: %w", path, err)
149+ }
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+ }
155+ return nil
156+ },
157+ }
158+}
159+
160+func newConfigUnsetCmd(env *cliEnv) *cobra.Command {
161+ return &cobra.Command{
162+ Use: "unset <url|token.cmd>",
163+ Short: "Remove a setting from the config file",
164+ Args: cobra.ExactArgs(1),
165+ ValidArgsFunction: completeConfigKeys,
166+ RunE: func(cmd *cobra.Command, args []string) error {
167+ key := args[0]
168+ if err := checkConfigKey(key); err != nil {
169+ return err
170+ }
171+ path, err := env.path()
172+ if err != nil {
173+ return err
174+ }
175+ data, err := kvconf.ReadFile(path, kvconf.Options{})
176+ if errors.Is(err, fs.ErrNotExist) {
177+ noticef(cmd, "%s is not set: %s does not exist", key, path)
178+ return nil
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+ }
191+ if err := kvconf.SetFile(path, map[string]*string{key: nil}, 0o600); err != nil {
192+ return fmt.Errorf("writing %s: %w", path, err)
193+ }
194+ noticef(cmd, "%s removed from %s", key, path)
195+ return nil
196+ },
197+ }
198+}
199+
200+func checkConfigKey(key string) error {
201+ for _, k := range configKeys {
202+ if key == k {
203+ return nil
204+ }
205+ }
206+ return fmt.Errorf("unknown key %q: the keys are %s", key, strings.Join(configKeys, " and "))
207+}
208+
209+func completeConfigKeys(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
210+ if len(args) > 0 {
211+ return nil, cobra.ShellCompDirectiveNoFileComp
212+ }
213+ return configKeys, cobra.ShellCompDirectiveNoFileComp
214+}
@@ -0,0 +1,214 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "io/fs"
9+ "slices"
10+ "strings"
11+
12+ "github.com/bnema/kvconf"
13+ "github.com/spf13/cobra"
14+
15+ "bnema.dev/ermite/internal/adapters/client"
16+)
17+
18+// notSet is what `config show` prints for a key without a value.
19+const notSet = "(not set)"
20+
21+// configKeys are the keys of the CLI config file.
22+var configKeys = []string{keyURL, keyTokenCmd}
23+
24+func newConfigCmd(env *cliEnv) *cobra.Command {
25+ cmd := &cobra.Command{
26+ Use: "config",
27+ Short: "Show and edit the config file of the remote commands",
28+ Long: "The remote commands read the server address and a way to get a token from a config file,\n" +
29+ "~/.config/ermite/config (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" +
31+ " url = https://git.example.com\n" +
32+ " token.cmd = pass show ermite/token\n\n" +
33+ "token.cmd is a command line run with the system shell when a command needs the token (at most\n" +
34+ "once per run): it must print the token alone on one line. Use any secret manager (pass, gopass,\n" +
35+ "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\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" +
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).\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).",
43+ }
44+ cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env))
45+ return cmd
46+}
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+
55+func newConfigShowCmd(env *cliEnv) *cobra.Command {
56+ cmd := &cobra.Command{
57+ Use: "show",
58+ Short: "Print the config file path and its settings",
59+ Long: "Print the path of the config file, then the url and token.cmd stored in it ((not set) when\n" +
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.",
62+ Args: cobra.NoArgs,
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+ }
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
103+ }
104+ return &s
105+}
106+
107+func orNotSet(s string) string {
108+ if strings.TrimSpace(s) == "" {
109+ return notSet
110+ }
111+ return s
112+}
113+
114+func newConfigSetCmd(env *cliEnv) *cobra.Command {
115+ return &cobra.Command{
116+ Use: "set <url|token.cmd> <value>",
117+ Short: "Set the server address or the token command in the config file",
118+ Long: "Write a setting to the config file, keeping its comments, and create the file (mode 0600) if needed.\n" +
119+ "url is checked: an http(s) address without credentials. token.cmd is a command line that prints the\n" +
120+ "token, for example `pass show ermite/token`; it is stored as is and not run.",
121+ Args: cobra.ExactArgs(2),
122+ ValidArgsFunction: completeConfigKeys,
123+ RunE: func(cmd *cobra.Command, args []string) error {
124+ key, value := args[0], strings.TrimSpace(args[1])
125+ if err := checkConfigKey(key); err != nil {
126+ return err
127+ }
128+ switch key {
129+ case keyURL:
130+ u, err := client.ParseURL(value)
131+ if err != nil {
132+ return fmt.Errorf("invalid url: %w", err)
133+ }
134+ value = u.String()
135+ case keyTokenCmd:
136+ if value == "" {
137+ return errors.New("token.cmd is empty: use `ermite config unset token.cmd` to remove it")
138+ }
139+ if strings.ContainsAny(value, "\r\n") {
140+ return errors.New("token.cmd must be a single line")
141+ }
142+ }
143+ path, err := env.path()
144+ if err != nil {
145+ return err
146+ }
147+ if err := kvconf.SetFile(path, map[string]*string{key: &value}, 0o600); err != nil {
148+ return fmt.Errorf("writing %s: %w", path, err)
149+ }
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+ }
155+ return nil
156+ },
157+ }
158+}
159+
160+func newConfigUnsetCmd(env *cliEnv) *cobra.Command {
161+ return &cobra.Command{
162+ Use: "unset <url|token.cmd>",
163+ Short: "Remove a setting from the config file",
164+ Args: cobra.ExactArgs(1),
165+ ValidArgsFunction: completeConfigKeys,
166+ RunE: func(cmd *cobra.Command, args []string) error {
167+ key := args[0]
168+ if err := checkConfigKey(key); err != nil {
169+ return err
170+ }
171+ path, err := env.path()
172+ if err != nil {
173+ return err
174+ }
175+ data, err := kvconf.ReadFile(path, kvconf.Options{})
176+ if errors.Is(err, fs.ErrNotExist) {
177+ noticef(cmd, "%s is not set: %s does not exist", key, path)
178+ return nil
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+ }
191+ if err := kvconf.SetFile(path, map[string]*string{key: nil}, 0o600); err != nil {
192+ return fmt.Errorf("writing %s: %w", path, err)
193+ }
194+ noticef(cmd, "%s removed from %s", key, path)
195+ return nil
196+ },
197+ }
198+}
199+
200+func checkConfigKey(key string) error {
201+ for _, k := range configKeys {
202+ if key == k {
203+ return nil
204+ }
205+ }
206+ return fmt.Errorf("unknown key %q: the keys are %s", key, strings.Join(configKeys, " and "))
207+}
208+
209+func completeConfigKeys(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
210+ if len(args) > 0 {
211+ return nil, cobra.ShellCompDirectiveNoFileComp
212+ }
213+ return configKeys, cobra.ShellCompDirectiveNoFileComp
214+}
A internal/app/cli_config_ci_test.go +114 -0
@@ -0,0 +1,114 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+
12+ "bnema.dev/ermite-api/gen/ermite"
13+)
14+
15+// The RunService calls of the `run` and `runner` commands answer with empty
16+// results: these tests look at which address and token reach the server, not
17+// at the output.
18+func (f *fakeAPI) ListRuns(_ context.Context, r *connect.Request[ermite.ListRunsRequest]) (*connect.Response[ermite.ListRunsResponse], error) {
19+ return respond(f, "ListRuns", r, func(*ermite.ListRunsRequest) (*ermite.ListRunsResponse, error) {
20+ return &ermite.ListRunsResponse{}, nil
21+ })
22+}
23+
24+func (f *fakeAPI) CancelRun(_ context.Context, r *connect.Request[ermite.CancelRunRequest]) (*connect.Response[ermite.CancelRunResponse], error) {
25+ return respond(f, "CancelRun", r, func(*ermite.CancelRunRequest) (*ermite.CancelRunResponse, error) {
26+ return &ermite.CancelRunResponse{}, nil
27+ })
28+}
29+
30+func (f *fakeAPI) RerunRun(_ context.Context, r *connect.Request[ermite.RerunRunRequest]) (*connect.Response[ermite.RerunRunResponse], error) {
31+ return respond(f, "RerunRun", r, func(*ermite.RerunRunRequest) (*ermite.RerunRunResponse, error) {
32+ return &ermite.RerunRunResponse{}, nil
33+ })
34+}
35+
36+func (f *fakeAPI) ListRunners(_ context.Context, r *connect.Request[ermite.ListRunnersRequest]) (*connect.Response[ermite.ListRunnersResponse], error) {
37+ return respond(f, "ListRunners", r, func(*ermite.ListRunnersRequest) (*ermite.ListRunnersResponse, error) {
38+ return &ermite.ListRunnersResponse{Runners: []*ermite.Runner{{Id: "r1", Name: "alpha"}}}, nil
39+ })
40+}
41+
42+func (f *fakeAPI) RemoveRunner(_ context.Context, r *connect.Request[ermite.RemoveRunnerRequest]) (*connect.Response[ermite.RemoveRunnerResponse], error) {
43+ return respond(f, "RemoveRunner", r, func(*ermite.RemoveRunnerRequest) (*ermite.RemoveRunnerResponse, error) {
44+ return &ermite.RemoveRunnerResponse{}, nil
45+ })
46+}
47+
48+// The `run` and `runner` commands read the server address and the token like
49+// every other remote command: the config file (url, token.cmd), with
50+// ERMITE_URL / ERMITE_TOKEN winning, and token.cmd run once and only for the
51+// file's own url.
52+func TestCIcommandsUseTheConfigFile(t *testing.T) {
53+ commands := map[string][]string{
54+ "run list": {"run", "list", "proj"},
55+ "run cancel": {"run", "cancel", "proj", "1"},
56+ "run rerun": {"run", "rerun", "proj", "1"},
57+ "runner list": {"runner", "list"},
58+ "runner remove": {"runner", "remove", "r1"},
59+ }
60+ for name, args := range commands {
61+ t.Run(name, func(t *testing.T) {
62+ f := &fakeAPI{}
63+ url := f.serve(t)
64+
65+ // url and token.cmd from the file
66+ env := testEnv(t, nil)
67+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
68+ ran := tokenCmdStub(env, "ermite_cmd\n", nil)
69+ runCLIWith(t, env, "", args...).ok(t)
70+ assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
71+ assert.Len(t, *ran, 1)
72+
73+ // ERMITE_TOKEN wins and token.cmd stays idle
74+ env = testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"})
75+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
76+ ran = tokenCmdStub(env, "ermite_cmd", nil)
77+ runCLIWith(t, env, "", args...).ok(t)
78+ assert.Equal(t, "Bearer ermite_env", lastAuth(f))
79+ assert.Empty(t, *ran)
80+
81+ // another ERMITE_URL never receives the file's token
82+ other := &fakeAPI{}
83+ env = testEnv(t, map[string]string{"ERMITE_URL": other.serve(t)})
84+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
85+ ran = tokenCmdStub(env, "ermite_cmd", nil)
86+ runCLIWith(t, env, "", args...).ok(t)
87+ assert.Empty(t, lastAuth(other))
88+ assert.Empty(t, *ran)
89+ })
90+ }
91+}
92+
93+// Completing a repository name for `run` or a runner id for `runner remove`
94+// must not run token.cmd: it may prompt, and a completion script cannot answer.
95+func TestCICompletionNeverRunsTokenCmd(t *testing.T) {
96+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
97+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
98+ }}
99+ url := f.serve(t)
100+ for name, args := range map[string][]string{
101+ "run list": {"__complete", "run", "list", ""},
102+ "run cancel": {"__complete", "run", "cancel", ""},
103+ "runner remove": {"__complete", "runner", "remove", ""},
104+ } {
105+ t.Run(name, func(t *testing.T) {
106+ env := testEnv(t, nil)
107+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
108+ ran := tokenCmdStub(env, "ermite_cmd", nil)
109+ runCLIWith(t, env, "", args...).ok(t)
110+ assert.Empty(t, *ran)
111+ assert.Empty(t, lastAuth(f))
112+ })
113+ }
114+}
@@ -0,0 +1,114 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+
12+ "bnema.dev/ermite-api/gen/ermite"
13+)
14+
15+// The RunService calls of the `run` and `runner` commands answer with empty
16+// results: these tests look at which address and token reach the server, not
17+// at the output.
18+func (f *fakeAPI) ListRuns(_ context.Context, r *connect.Request[ermite.ListRunsRequest]) (*connect.Response[ermite.ListRunsResponse], error) {
19+ return respond(f, "ListRuns", r, func(*ermite.ListRunsRequest) (*ermite.ListRunsResponse, error) {
20+ return &ermite.ListRunsResponse{}, nil
21+ })
22+}
23+
24+func (f *fakeAPI) CancelRun(_ context.Context, r *connect.Request[ermite.CancelRunRequest]) (*connect.Response[ermite.CancelRunResponse], error) {
25+ return respond(f, "CancelRun", r, func(*ermite.CancelRunRequest) (*ermite.CancelRunResponse, error) {
26+ return &ermite.CancelRunResponse{}, nil
27+ })
28+}
29+
30+func (f *fakeAPI) RerunRun(_ context.Context, r *connect.Request[ermite.RerunRunRequest]) (*connect.Response[ermite.RerunRunResponse], error) {
31+ return respond(f, "RerunRun", r, func(*ermite.RerunRunRequest) (*ermite.RerunRunResponse, error) {
32+ return &ermite.RerunRunResponse{}, nil
33+ })
34+}
35+
36+func (f *fakeAPI) ListRunners(_ context.Context, r *connect.Request[ermite.ListRunnersRequest]) (*connect.Response[ermite.ListRunnersResponse], error) {
37+ return respond(f, "ListRunners", r, func(*ermite.ListRunnersRequest) (*ermite.ListRunnersResponse, error) {
38+ return &ermite.ListRunnersResponse{Runners: []*ermite.Runner{{Id: "r1", Name: "alpha"}}}, nil
39+ })
40+}
41+
42+func (f *fakeAPI) RemoveRunner(_ context.Context, r *connect.Request[ermite.RemoveRunnerRequest]) (*connect.Response[ermite.RemoveRunnerResponse], error) {
43+ return respond(f, "RemoveRunner", r, func(*ermite.RemoveRunnerRequest) (*ermite.RemoveRunnerResponse, error) {
44+ return &ermite.RemoveRunnerResponse{}, nil
45+ })
46+}
47+
48+// The `run` and `runner` commands read the server address and the token like
49+// every other remote command: the config file (url, token.cmd), with
50+// ERMITE_URL / ERMITE_TOKEN winning, and token.cmd run once and only for the
51+// file's own url.
52+func TestCIcommandsUseTheConfigFile(t *testing.T) {
53+ commands := map[string][]string{
54+ "run list": {"run", "list", "proj"},
55+ "run cancel": {"run", "cancel", "proj", "1"},
56+ "run rerun": {"run", "rerun", "proj", "1"},
57+ "runner list": {"runner", "list"},
58+ "runner remove": {"runner", "remove", "r1"},
59+ }
60+ for name, args := range commands {
61+ t.Run(name, func(t *testing.T) {
62+ f := &fakeAPI{}
63+ url := f.serve(t)
64+
65+ // url and token.cmd from the file
66+ env := testEnv(t, nil)
67+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
68+ ran := tokenCmdStub(env, "ermite_cmd\n", nil)
69+ runCLIWith(t, env, "", args...).ok(t)
70+ assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
71+ assert.Len(t, *ran, 1)
72+
73+ // ERMITE_TOKEN wins and token.cmd stays idle
74+ env = testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"})
75+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
76+ ran = tokenCmdStub(env, "ermite_cmd", nil)
77+ runCLIWith(t, env, "", args...).ok(t)
78+ assert.Equal(t, "Bearer ermite_env", lastAuth(f))
79+ assert.Empty(t, *ran)
80+
81+ // another ERMITE_URL never receives the file's token
82+ other := &fakeAPI{}
83+ env = testEnv(t, map[string]string{"ERMITE_URL": other.serve(t)})
84+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
85+ ran = tokenCmdStub(env, "ermite_cmd", nil)
86+ runCLIWith(t, env, "", args...).ok(t)
87+ assert.Empty(t, lastAuth(other))
88+ assert.Empty(t, *ran)
89+ })
90+ }
91+}
92+
93+// Completing a repository name for `run` or a runner id for `runner remove`
94+// must not run token.cmd: it may prompt, and a completion script cannot answer.
95+func TestCICompletionNeverRunsTokenCmd(t *testing.T) {
96+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
97+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
98+ }}
99+ url := f.serve(t)
100+ for name, args := range map[string][]string{
101+ "run list": {"__complete", "run", "list", ""},
102+ "run cancel": {"__complete", "run", "cancel", ""},
103+ "runner remove": {"__complete", "runner", "remove", ""},
104+ } {
105+ t.Run(name, func(t *testing.T) {
106+ env := testEnv(t, nil)
107+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
108+ ran := tokenCmdStub(env, "ermite_cmd", nil)
109+ runCLIWith(t, env, "", args...).ok(t)
110+ assert.Empty(t, *ran)
111+ assert.Empty(t, lastAuth(f))
112+ })
113+ }
114+}
A internal/app/cli_config_test.go +235 -0
@@ -0,0 +1,235 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "os"
9+ "path/filepath"
10+ "runtime"
11+ "strconv"
12+ "strings"
13+ "testing"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func TestConfigShowWithoutFile(t *testing.T) {
20+ env := testEnv(t, nil)
21+ path, _ := env.configPath()
22+ res := runCLIWith(t, env, "", "config", "show").ok(t)
23+ assert.Equal(t, "file "+path+"\nurl (not set)\ntoken.cmd (not set)\n", res.stdout)
24+ _, err := os.Stat(path)
25+ assert.ErrorIs(t, err, os.ErrNotExist, "show does not create the file")
26+}
27+
28+func TestConfigSetUnsetShowRoundTrip(t *testing.T) {
29+ env := testEnv(t, nil)
30+ path, _ := env.configPath()
31+ writeConfigFile(t, env, "# my forge\nurl = https://old.example.com\n\n# secrets stay in pass\n", 0o600)
32+
33+ runCLIWith(t, env, "", "config", "set", "url", "https://git.example.com/").ok(t)
34+ runCLIWith(t, env, "", "config", "set", "token.cmd", "pass show ermite/token").ok(t)
35+ // a fresh process sees the new content
36+ res := runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t)
37+ assert.Contains(t, res.stdout, "url https://git.example.com\n")
38+ assert.Contains(t, res.stdout, "token.cmd pass show ermite/token\n")
39+
40+ data, err := os.ReadFile(path)
41+ require.NoError(t, err)
42+ assert.Contains(t, string(data), "# my forge\n")
43+ assert.Contains(t, string(data), "# secrets stay in pass\n")
44+
45+ runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "token.cmd").ok(t)
46+ runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "url").ok(t)
47+ data, err = os.ReadFile(path)
48+ require.NoError(t, err)
49+ assert.NotContains(t, string(data), "token.cmd")
50+ assert.NotContains(t, string(data), "url")
51+ assert.Contains(t, string(data), "# my forge\n", "comments survive")
52+
53+ res = runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t)
54+ assert.Contains(t, res.stdout, "url (not set)\n")
55+ assert.Contains(t, res.stdout, "token.cmd (not set)\n")
56+}
57+
58+// testEnvAt is testEnv with the config file at path: a new process.
59+func testEnvAt(t *testing.T, path string) *cliEnv {
60+ t.Helper()
61+ env := testEnv(t, nil)
62+ env.configPath = func() (string, error) { return path, nil }
63+ return env
64+}
65+
66+func TestConfigSetCreatesPrivateFile(t *testing.T) {
67+ env := testEnv(t, nil)
68+ path, _ := env.configPath()
69+ res := runCLIWith(t, env, "", "config", "set", "token.cmd", "gopass show -o ermite").ok(t)
70+ assert.Contains(t, res.stderr, path)
71+ info, err := os.Stat(path)
72+ require.NoError(t, err)
73+ if runtime.GOOS != "windows" {
74+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
75+ }
76+ data, _ := os.ReadFile(path)
77+ assert.Equal(t, "token.cmd = gopass show -o ermite\n", string(data))
78+}
79+
80+func TestConfigSetRefusesBadInput(t *testing.T) {
81+ tests := []struct {
82+ name string
83+ args []string
84+ want string
85+ }{
86+ {"unknown key", []string{"set", "token", "x"}, `unknown key "token": the keys are url and token.cmd`},
87+ {"unknown key unset", []string{"unset", "colour"}, `unknown key "colour": the keys are url and token.cmd`},
88+ {"no scheme", []string{"set", "url", "git.example.com"}, "invalid url"},
89+ {"credentials", []string{"set", "url", "https://u:s3cret@git.example.com"}, "must not contain credentials"},
90+ {"ftp", []string{"set", "url", "ftp://git.example.com"}, "invalid url"},
91+ {"empty token.cmd", []string{"set", "token.cmd", " "}, "token.cmd is empty"},
92+ {"multiline token.cmd", []string{"set", "token.cmd", "a\nb"}, "single line"},
93+ {"missing value", []string{"set", "url"}, "accepts 2 arg"},
94+ }
95+ for _, tt := range tests {
96+ t.Run(tt.name, func(t *testing.T) {
97+ env := testEnv(t, nil)
98+ path, _ := env.configPath()
99+ res := runCLIWith(t, env, "", append([]string{"config"}, tt.args...)...)
100+ require.Error(t, res.err)
101+ assert.Contains(t, res.err.Error(), tt.want)
102+ assert.NotContains(t, res.err.Error(), "s3cret")
103+ _, err := os.Stat(path)
104+ assert.ErrorIs(t, err, os.ErrNotExist, "nothing is written")
105+ })
106+ }
107+}
108+
109+func TestConfigUnsetWithoutFile(t *testing.T) {
110+ env := testEnv(t, nil)
111+ path, _ := env.configPath()
112+ res := runCLIWith(t, env, "", "config", "unset", "url").ok(t)
113+ assert.Contains(t, res.stderr, "does not exist")
114+ _, err := os.Stat(path)
115+ assert.ErrorIs(t, err, os.ErrNotExist)
116+}
117+
118+func TestConfigShowNeverRunsTokenCmdAndNotesPrecedence(t *testing.T) {
119+ env := testEnv(t, map[string]string{"ERMITE_URL": "https://env.example.com", "ERMITE_TOKEN": "ermite_x"})
120+ writeConfigFile(t, env, "url = https://file.example.com\ntoken.cmd = get-token\n", 0o666)
121+ res := runCLIWith(t, env, "", "config", "show").ok(t) // testEnv fails the test if token.cmd runs
122+ assert.Contains(t, res.stdout, "url https://file.example.com\n", "show prints what is stored")
123+ assert.Contains(t, res.stderr, "ERMITE_URL is set and takes precedence")
124+ assert.Contains(t, res.stderr, "ERMITE_TOKEN is set: token.cmd is not used")
125+ assert.False(t, strings.Contains(res.stdout+res.stderr, "ermite_x"), "the token is never printed")
126+}
127+
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)
136+ }
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")
139+}
140+
141+func TestConfigShowNamesTheFileItCannotRead(t *testing.T) {
142+ env := testEnv(t, nil)
143+ path := writeConfigFile(t, env, "url = https://git.example.com\n", 0o600)
144+ require.NoError(t, os.Remove(path))
145+ require.NoError(t, os.Mkdir(path, 0o700)) // a directory is not a config file
146+ res := runCLIWith(t, env, "", "config", "show")
147+ require.Error(t, res.err)
148+ assert.Contains(t, res.stdout, path)
149+ assert.Contains(t, res.err.Error(), "config:")
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+}
@@ -0,0 +1,235 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "os"
9+ "path/filepath"
10+ "runtime"
11+ "strconv"
12+ "strings"
13+ "testing"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func TestConfigShowWithoutFile(t *testing.T) {
20+ env := testEnv(t, nil)
21+ path, _ := env.configPath()
22+ res := runCLIWith(t, env, "", "config", "show").ok(t)
23+ assert.Equal(t, "file "+path+"\nurl (not set)\ntoken.cmd (not set)\n", res.stdout)
24+ _, err := os.Stat(path)
25+ assert.ErrorIs(t, err, os.ErrNotExist, "show does not create the file")
26+}
27+
28+func TestConfigSetUnsetShowRoundTrip(t *testing.T) {
29+ env := testEnv(t, nil)
30+ path, _ := env.configPath()
31+ writeConfigFile(t, env, "# my forge\nurl = https://old.example.com\n\n# secrets stay in pass\n", 0o600)
32+
33+ runCLIWith(t, env, "", "config", "set", "url", "https://git.example.com/").ok(t)
34+ runCLIWith(t, env, "", "config", "set", "token.cmd", "pass show ermite/token").ok(t)
35+ // a fresh process sees the new content
36+ res := runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t)
37+ assert.Contains(t, res.stdout, "url https://git.example.com\n")
38+ assert.Contains(t, res.stdout, "token.cmd pass show ermite/token\n")
39+
40+ data, err := os.ReadFile(path)
41+ require.NoError(t, err)
42+ assert.Contains(t, string(data), "# my forge\n")
43+ assert.Contains(t, string(data), "# secrets stay in pass\n")
44+
45+ runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "token.cmd").ok(t)
46+ runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "url").ok(t)
47+ data, err = os.ReadFile(path)
48+ require.NoError(t, err)
49+ assert.NotContains(t, string(data), "token.cmd")
50+ assert.NotContains(t, string(data), "url")
51+ assert.Contains(t, string(data), "# my forge\n", "comments survive")
52+
53+ res = runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t)
54+ assert.Contains(t, res.stdout, "url (not set)\n")
55+ assert.Contains(t, res.stdout, "token.cmd (not set)\n")
56+}
57+
58+// testEnvAt is testEnv with the config file at path: a new process.
59+func testEnvAt(t *testing.T, path string) *cliEnv {
60+ t.Helper()
61+ env := testEnv(t, nil)
62+ env.configPath = func() (string, error) { return path, nil }
63+ return env
64+}
65+
66+func TestConfigSetCreatesPrivateFile(t *testing.T) {
67+ env := testEnv(t, nil)
68+ path, _ := env.configPath()
69+ res := runCLIWith(t, env, "", "config", "set", "token.cmd", "gopass show -o ermite").ok(t)
70+ assert.Contains(t, res.stderr, path)
71+ info, err := os.Stat(path)
72+ require.NoError(t, err)
73+ if runtime.GOOS != "windows" {
74+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
75+ }
76+ data, _ := os.ReadFile(path)
77+ assert.Equal(t, "token.cmd = gopass show -o ermite\n", string(data))
78+}
79+
80+func TestConfigSetRefusesBadInput(t *testing.T) {
81+ tests := []struct {
82+ name string
83+ args []string
84+ want string
85+ }{
86+ {"unknown key", []string{"set", "token", "x"}, `unknown key "token": the keys are url and token.cmd`},
87+ {"unknown key unset", []string{"unset", "colour"}, `unknown key "colour": the keys are url and token.cmd`},
88+ {"no scheme", []string{"set", "url", "git.example.com"}, "invalid url"},
89+ {"credentials", []string{"set", "url", "https://u:s3cret@git.example.com"}, "must not contain credentials"},
90+ {"ftp", []string{"set", "url", "ftp://git.example.com"}, "invalid url"},
91+ {"empty token.cmd", []string{"set", "token.cmd", " "}, "token.cmd is empty"},
92+ {"multiline token.cmd", []string{"set", "token.cmd", "a\nb"}, "single line"},
93+ {"missing value", []string{"set", "url"}, "accepts 2 arg"},
94+ }
95+ for _, tt := range tests {
96+ t.Run(tt.name, func(t *testing.T) {
97+ env := testEnv(t, nil)
98+ path, _ := env.configPath()
99+ res := runCLIWith(t, env, "", append([]string{"config"}, tt.args...)...)
100+ require.Error(t, res.err)
101+ assert.Contains(t, res.err.Error(), tt.want)
102+ assert.NotContains(t, res.err.Error(), "s3cret")
103+ _, err := os.Stat(path)
104+ assert.ErrorIs(t, err, os.ErrNotExist, "nothing is written")
105+ })
106+ }
107+}
108+
109+func TestConfigUnsetWithoutFile(t *testing.T) {
110+ env := testEnv(t, nil)
111+ path, _ := env.configPath()
112+ res := runCLIWith(t, env, "", "config", "unset", "url").ok(t)
113+ assert.Contains(t, res.stderr, "does not exist")
114+ _, err := os.Stat(path)
115+ assert.ErrorIs(t, err, os.ErrNotExist)
116+}
117+
118+func TestConfigShowNeverRunsTokenCmdAndNotesPrecedence(t *testing.T) {
119+ env := testEnv(t, map[string]string{"ERMITE_URL": "https://env.example.com", "ERMITE_TOKEN": "ermite_x"})
120+ writeConfigFile(t, env, "url = https://file.example.com\ntoken.cmd = get-token\n", 0o666)
121+ res := runCLIWith(t, env, "", "config", "show").ok(t) // testEnv fails the test if token.cmd runs
122+ assert.Contains(t, res.stdout, "url https://file.example.com\n", "show prints what is stored")
123+ assert.Contains(t, res.stderr, "ERMITE_URL is set and takes precedence")
124+ assert.Contains(t, res.stderr, "ERMITE_TOKEN is set: token.cmd is not used")
125+ assert.False(t, strings.Contains(res.stdout+res.stderr, "ermite_x"), "the token is never printed")
126+}
127+
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)
136+ }
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")
139+}
140+
141+func TestConfigShowNamesTheFileItCannotRead(t *testing.T) {
142+ env := testEnv(t, nil)
143+ path := writeConfigFile(t, env, "url = https://git.example.com\n", 0o600)
144+ require.NoError(t, os.Remove(path))
145+ require.NoError(t, os.Mkdir(path, 0o700)) // a directory is not a config file
146+ res := runCLIWith(t, env, "", "config", "show")
147+ require.Error(t, res.err)
148+ assert.Contains(t, res.stdout, path)
149+ assert.Contains(t, res.err.Error(), "config:")
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+}
A internal/app/cli_configfile.go +272 -0
@@ -0,0 +1,272 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "io/fs"
12+ "os"
13+ "os/exec"
14+ "os/signal"
15+ "strings"
16+ "syscall"
17+ "time"
18+
19+ "github.com/bnema/kvconf"
20+ "github.com/spf13/cobra"
21+ "golang.org/x/term"
22+
23+ "bnema.dev/ermite/internal/adapters/client"
24+)
25+
26+const (
27+ // envConfig selects the CLI config file instead of ~/.config/ermite/config.
28+ envConfig = "ERMITE_CONFIG"
29+ // Keys of the CLI config file.
30+ keyURL = "url"
31+ keyTokenCmd = "token.cmd"
32+
33+ // tokenCmdTimeout bounds one run of token.cmd, prompts included.
34+ tokenCmdTimeout = 60 * time.Second
35+ // tokenCmdMaxOutput bounds what is read from its standard output.
36+ tokenCmdMaxOutput = 4 << 10
37+)
38+
39+// cliFile is the CLI config file. It holds no secret: the token is fetched
40+// from a secret manager by the token.cmd command line.
41+type cliFile struct {
42+ URL string `kv:"url"`
43+ TokenCmd string `kv:"token.cmd"`
44+}
45+
46+// path is the config file: $ERMITE_CONFIG, else <config dir>/ermite/config.
47+func (e *cliEnv) path() (string, error) {
48+ if e.configPath != nil {
49+ return e.configPath()
50+ }
51+ if p := strings.TrimSpace(e.getenv(envConfig)); p != "" {
52+ return p, nil
53+ }
54+ p, err := kvconf.DefaultPath("ermite", "config")
55+ if err != nil {
56+ return "", fmt.Errorf("no home directory to find the config file: set %s", envConfig)
57+ }
58+ return p, nil
59+}
60+
61+// readFile reads the config file, once per process. A missing file, or no
62+// place to look for it, gives an empty one. Problems in the file are warnings
63+// on stderr, printed once, and only when warn is set. The path is returned
64+// even when reading fails.
65+//
66+// The file is opened once: what is parsed and what checkPrivate vets (see
67+// fetchToken) are the same file, so token.cmd cannot come from a file that was
68+// swapped after the check.
69+func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, error) {
70+ e.fileOnce.Do(func() {
71+ p, err := e.path()
72+ if err != nil {
73+ // Reading is best effort when the location is unknown; the
74+ // `ermite config` commands report this error.
75+ e.file = &cliFile{}
76+ return
77+ }
78+ e.filePath = p
79+ f, warnings, trust, err := loadConfigFile(p)
80+ if err != nil {
81+ e.fileErr = fmt.Errorf("config: %w", err)
82+ return
83+ }
84+ e.fileTrust = trust
85+ if warn {
86+ for _, w := range warnings {
87+ warnf(cmd, "config: %s", w)
88+ }
89+ }
90+ e.file = f
91+ })
92+ return e.file, e.filePath, e.fileErr
93+}
94+
95+// loadConfigFile parses the file at path (missing: empty). trust is nil when
96+// token.cmd may be run from this file, else the reason why not.
97+func loadConfigFile(path string) (f *cliFile, warnings []kvconf.Warning, trust, err error) {
98+ h, err := openConfig(path)
99+ if errors.Is(err, fs.ErrNotExist) {
100+ return &cliFile{}, nil, nil, nil
101+ }
102+ if err != nil {
103+ return nil, nil, nil, err
104+ }
105+ defer h.Close() //nolint:errcheck // read only
106+ info, err := h.Stat()
107+ if err != nil {
108+ return nil, nil, nil, err
109+ }
110+ if !info.Mode().IsRegular() {
111+ return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrNotRegular)
112+ }
113+ trust = checkPrivate(path, info, os.Geteuid())
114+ data, err := io.ReadAll(io.LimitReader(h, kvconf.DefaultMaxBytes+1))
115+ if err != nil {
116+ return nil, nil, nil, err
117+ }
118+ if len(data) > kvconf.DefaultMaxBytes {
119+ return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrTooLarge)
120+ }
121+ f = &cliFile{}
122+ warnings, err = kvconf.Unmarshal(data, f, kvconf.Options{})
123+ if err != nil {
124+ return nil, nil, nil, fmt.Errorf("%s: %w", path, err)
125+ }
126+ return f, warnings, trust, nil
127+}
128+
129+// urlSource says where the server address in use comes from, for error messages.
130+func (e *cliEnv) urlSource() string {
131+ if strings.TrimSpace(e.getenv(client.EnvURL)) != "" {
132+ return client.EnvURL
133+ }
134+ return fmt.Sprintf("url in %s (fix it with `ermite config set url <url>`)", e.filePath)
135+}
136+
137+// resolveURL is ERMITE_URL if set, else the url of the config file, else "".
138+func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) {
139+ if v := strings.TrimSpace(e.getenv(client.EnvURL)); v != "" {
140+ return v, nil
141+ }
142+ f, _, err := e.readFile(cmd, warn)
143+ if err != nil {
144+ return "", err
145+ }
146+ return strings.TrimSpace(f.URL), nil
147+}
148+
149+// tokenFromCmd is the output of token.cmd, or "" when the file sets none or
150+// when ERMITE_URL points somewhere else than the url of the file: the token
151+// the command fetches belongs to the server of the file, and must not be sent
152+// to another one.
153+func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) {
154+ f, _, err := e.readFile(cmd, warn)
155+ if err != nil {
156+ return "", err
157+ }
158+ line := strings.TrimSpace(f.TokenCmd)
159+ if line == "" {
160+ return "", nil
161+ }
162+ e.tokenOnce.Do(func() {
163+ if !e.urlIsFromFile(f) {
164+ if warn {
165+ noticef(cmd, "%s differs from the config file url: token.cmd not run (set %s)", client.EnvURL, client.EnvToken)
166+ }
167+ return
168+ }
169+ e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), line)
170+ })
171+ return e.token, e.tokenErr
172+}
173+
174+// urlIsFromFile reports whether the server in use is the one of the config
175+// file: ERMITE_URL is unset, or names the same address as the file's url.
176+func (e *cliEnv) urlIsFromFile(f *cliFile) bool {
177+ envURL := strings.TrimSpace(e.getenv(client.EnvURL))
178+ if envURL == "" {
179+ return true
180+ }
181+ a, err := client.ParseURL(envURL)
182+ if err != nil {
183+ return false
184+ }
185+ b, err := client.ParseURL(strings.TrimSpace(f.URL))
186+ return err == nil && a.String() == b.String()
187+}
188+
189+// fetchToken runs token.cmd. Neither its output nor its error text reach the
190+// caller's messages: the output is the secret.
191+func (e *cliEnv) fetchToken(ctx context.Context, line string) (string, error) {
192+ if e.fileTrust != nil {
193+ return "", e.fileTrust
194+ }
195+ out, err := e.runTokenCmd(ctx, line)
196+ if err != nil {
197+ return "", fmt.Errorf("token.cmd failed: %w", err)
198+ }
199+ out = strings.TrimSpace(out)
200+ switch {
201+ case out == "":
202+ return "", errors.New("token.cmd printed nothing")
203+ case strings.ContainsAny(out, "\r\n"):
204+ return "", errors.New("token.cmd printed more than one line")
205+ }
206+ return out, nil
207+}
208+
209+// runTokenShell is the default runTokenCmd: the system shell, for at most
210+// tokenCmdTimeout.
211+func runTokenShell(ctx context.Context, line string) (string, error) {
212+ return runShell(ctx, line, tokenCmdTimeout)
213+}
214+
215+// runShell runs line with the system shell. Standard error is the terminal's,
216+// and so is standard input when it is a terminal, so a pinentry or a biometric
217+// prompt works; a script or an agent gets no input. Standard output is
218+// captured, up to tokenCmdMaxOutput bytes. The environment is the process's.
219+// An interrupt or SIGTERM sent to ermite, or the end of ctx, stops the command
220+// like a timeout does (see shellCommand for what is killed).
221+func runShell(ctx context.Context, line string, timeout time.Duration) (string, error) {
222+ sigCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
223+ defer stop()
224+ runCtx, cancel := context.WithTimeout(sigCtx, timeout)
225+ defer cancel()
226+
227+ foreground := term.IsTerminal(int(os.Stdin.Fd()))
228+ c := shellCommand(runCtx, line, foreground)
229+ out := &cappedBuffer{max: tokenCmdMaxOutput}
230+ c.Stdout, c.Stderr = out, os.Stderr
231+ if foreground {
232+ c.Stdin = os.Stdin
233+ } // else nil: the command reads os.DevNull
234+ // Do not wait forever for a background process that kept the pipe open.
235+ c.WaitDelay = 2 * time.Second
236+ err := c.Run()
237+ if err != nil {
238+ switch {
239+ case sigCtx.Err() != nil:
240+ return "", errors.New("interrupted")
241+ case errors.Is(runCtx.Err(), context.DeadlineExceeded):
242+ return "", fmt.Errorf("timed out after %s", timeout)
243+ }
244+ if exit := (*exec.ExitError)(nil); errors.As(err, &exit) {
245+ return "", errors.New(exit.String())
246+ }
247+ return "", err
248+ }
249+ if out.over {
250+ return "", fmt.Errorf("printed more than %d bytes", tokenCmdMaxOutput)
251+ }
252+ return out.buf.String(), nil
253+}
254+
255+// cappedBuffer keeps the first max bytes written and notes an overflow.
256+type cappedBuffer struct {
257+ buf bytes.Buffer
258+ max int
259+ over bool
260+}
261+
262+func (b *cappedBuffer) Write(p []byte) (int, error) {
263+ n := len(p) // report everything as written: a short write would abort the copy
264+ if room := b.max - b.buf.Len(); len(p) > room {
265+ b.over = true
266+ p = p[:room]
267+ }
268+ b.buf.Write(p)
269+ return n, nil
270+}
271+
272+var _ io.Writer = (*cappedBuffer)(nil)
@@ -0,0 +1,272 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "io/fs"
12+ "os"
13+ "os/exec"
14+ "os/signal"
15+ "strings"
16+ "syscall"
17+ "time"
18+
19+ "github.com/bnema/kvconf"
20+ "github.com/spf13/cobra"
21+ "golang.org/x/term"
22+
23+ "bnema.dev/ermite/internal/adapters/client"
24+)
25+
26+const (
27+ // envConfig selects the CLI config file instead of ~/.config/ermite/config.
28+ envConfig = "ERMITE_CONFIG"
29+ // Keys of the CLI config file.
30+ keyURL = "url"
31+ keyTokenCmd = "token.cmd"
32+
33+ // tokenCmdTimeout bounds one run of token.cmd, prompts included.
34+ tokenCmdTimeout = 60 * time.Second
35+ // tokenCmdMaxOutput bounds what is read from its standard output.
36+ tokenCmdMaxOutput = 4 << 10
37+)
38+
39+// cliFile is the CLI config file. It holds no secret: the token is fetched
40+// from a secret manager by the token.cmd command line.
41+type cliFile struct {
42+ URL string `kv:"url"`
43+ TokenCmd string `kv:"token.cmd"`
44+}
45+
46+// path is the config file: $ERMITE_CONFIG, else <config dir>/ermite/config.
47+func (e *cliEnv) path() (string, error) {
48+ if e.configPath != nil {
49+ return e.configPath()
50+ }
51+ if p := strings.TrimSpace(e.getenv(envConfig)); p != "" {
52+ return p, nil
53+ }
54+ p, err := kvconf.DefaultPath("ermite", "config")
55+ if err != nil {
56+ return "", fmt.Errorf("no home directory to find the config file: set %s", envConfig)
57+ }
58+ return p, nil
59+}
60+
61+// readFile reads the config file, once per process. A missing file, or no
62+// place to look for it, gives an empty one. Problems in the file are warnings
63+// on stderr, printed once, and only when warn is set. The path is returned
64+// even when reading fails.
65+//
66+// The file is opened once: what is parsed and what checkPrivate vets (see
67+// fetchToken) are the same file, so token.cmd cannot come from a file that was
68+// swapped after the check.
69+func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, error) {
70+ e.fileOnce.Do(func() {
71+ p, err := e.path()
72+ if err != nil {
73+ // Reading is best effort when the location is unknown; the
74+ // `ermite config` commands report this error.
75+ e.file = &cliFile{}
76+ return
77+ }
78+ e.filePath = p
79+ f, warnings, trust, err := loadConfigFile(p)
80+ if err != nil {
81+ e.fileErr = fmt.Errorf("config: %w", err)
82+ return
83+ }
84+ e.fileTrust = trust
85+ if warn {
86+ for _, w := range warnings {
87+ warnf(cmd, "config: %s", w)
88+ }
89+ }
90+ e.file = f
91+ })
92+ return e.file, e.filePath, e.fileErr
93+}
94+
95+// loadConfigFile parses the file at path (missing: empty). trust is nil when
96+// token.cmd may be run from this file, else the reason why not.
97+func loadConfigFile(path string) (f *cliFile, warnings []kvconf.Warning, trust, err error) {
98+ h, err := openConfig(path)
99+ if errors.Is(err, fs.ErrNotExist) {
100+ return &cliFile{}, nil, nil, nil
101+ }
102+ if err != nil {
103+ return nil, nil, nil, err
104+ }
105+ defer h.Close() //nolint:errcheck // read only
106+ info, err := h.Stat()
107+ if err != nil {
108+ return nil, nil, nil, err
109+ }
110+ if !info.Mode().IsRegular() {
111+ return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrNotRegular)
112+ }
113+ trust = checkPrivate(path, info, os.Geteuid())
114+ data, err := io.ReadAll(io.LimitReader(h, kvconf.DefaultMaxBytes+1))
115+ if err != nil {
116+ return nil, nil, nil, err
117+ }
118+ if len(data) > kvconf.DefaultMaxBytes {
119+ return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrTooLarge)
120+ }
121+ f = &cliFile{}
122+ warnings, err = kvconf.Unmarshal(data, f, kvconf.Options{})
123+ if err != nil {
124+ return nil, nil, nil, fmt.Errorf("%s: %w", path, err)
125+ }
126+ return f, warnings, trust, nil
127+}
128+
129+// urlSource says where the server address in use comes from, for error messages.
130+func (e *cliEnv) urlSource() string {
131+ if strings.TrimSpace(e.getenv(client.EnvURL)) != "" {
132+ return client.EnvURL
133+ }
134+ return fmt.Sprintf("url in %s (fix it with `ermite config set url <url>`)", e.filePath)
135+}
136+
137+// resolveURL is ERMITE_URL if set, else the url of the config file, else "".
138+func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) {
139+ if v := strings.TrimSpace(e.getenv(client.EnvURL)); v != "" {
140+ return v, nil
141+ }
142+ f, _, err := e.readFile(cmd, warn)
143+ if err != nil {
144+ return "", err
145+ }
146+ return strings.TrimSpace(f.URL), nil
147+}
148+
149+// tokenFromCmd is the output of token.cmd, or "" when the file sets none or
150+// when ERMITE_URL points somewhere else than the url of the file: the token
151+// the command fetches belongs to the server of the file, and must not be sent
152+// to another one.
153+func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) {
154+ f, _, err := e.readFile(cmd, warn)
155+ if err != nil {
156+ return "", err
157+ }
158+ line := strings.TrimSpace(f.TokenCmd)
159+ if line == "" {
160+ return "", nil
161+ }
162+ e.tokenOnce.Do(func() {
163+ if !e.urlIsFromFile(f) {
164+ if warn {
165+ noticef(cmd, "%s differs from the config file url: token.cmd not run (set %s)", client.EnvURL, client.EnvToken)
166+ }
167+ return
168+ }
169+ e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), line)
170+ })
171+ return e.token, e.tokenErr
172+}
173+
174+// urlIsFromFile reports whether the server in use is the one of the config
175+// file: ERMITE_URL is unset, or names the same address as the file's url.
176+func (e *cliEnv) urlIsFromFile(f *cliFile) bool {
177+ envURL := strings.TrimSpace(e.getenv(client.EnvURL))
178+ if envURL == "" {
179+ return true
180+ }
181+ a, err := client.ParseURL(envURL)
182+ if err != nil {
183+ return false
184+ }
185+ b, err := client.ParseURL(strings.TrimSpace(f.URL))
186+ return err == nil && a.String() == b.String()
187+}
188+
189+// fetchToken runs token.cmd. Neither its output nor its error text reach the
190+// caller's messages: the output is the secret.
191+func (e *cliEnv) fetchToken(ctx context.Context, line string) (string, error) {
192+ if e.fileTrust != nil {
193+ return "", e.fileTrust
194+ }
195+ out, err := e.runTokenCmd(ctx, line)
196+ if err != nil {
197+ return "", fmt.Errorf("token.cmd failed: %w", err)
198+ }
199+ out = strings.TrimSpace(out)
200+ switch {
201+ case out == "":
202+ return "", errors.New("token.cmd printed nothing")
203+ case strings.ContainsAny(out, "\r\n"):
204+ return "", errors.New("token.cmd printed more than one line")
205+ }
206+ return out, nil
207+}
208+
209+// runTokenShell is the default runTokenCmd: the system shell, for at most
210+// tokenCmdTimeout.
211+func runTokenShell(ctx context.Context, line string) (string, error) {
212+ return runShell(ctx, line, tokenCmdTimeout)
213+}
214+
215+// runShell runs line with the system shell. Standard error is the terminal's,
216+// and so is standard input when it is a terminal, so a pinentry or a biometric
217+// prompt works; a script or an agent gets no input. Standard output is
218+// captured, up to tokenCmdMaxOutput bytes. The environment is the process's.
219+// An interrupt or SIGTERM sent to ermite, or the end of ctx, stops the command
220+// like a timeout does (see shellCommand for what is killed).
221+func runShell(ctx context.Context, line string, timeout time.Duration) (string, error) {
222+ sigCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
223+ defer stop()
224+ runCtx, cancel := context.WithTimeout(sigCtx, timeout)
225+ defer cancel()
226+
227+ foreground := term.IsTerminal(int(os.Stdin.Fd()))
228+ c := shellCommand(runCtx, line, foreground)
229+ out := &cappedBuffer{max: tokenCmdMaxOutput}
230+ c.Stdout, c.Stderr = out, os.Stderr
231+ if foreground {
232+ c.Stdin = os.Stdin
233+ } // else nil: the command reads os.DevNull
234+ // Do not wait forever for a background process that kept the pipe open.
235+ c.WaitDelay = 2 * time.Second
236+ err := c.Run()
237+ if err != nil {
238+ switch {
239+ case sigCtx.Err() != nil:
240+ return "", errors.New("interrupted")
241+ case errors.Is(runCtx.Err(), context.DeadlineExceeded):
242+ return "", fmt.Errorf("timed out after %s", timeout)
243+ }
244+ if exit := (*exec.ExitError)(nil); errors.As(err, &exit) {
245+ return "", errors.New(exit.String())
246+ }
247+ return "", err
248+ }
249+ if out.over {
250+ return "", fmt.Errorf("printed more than %d bytes", tokenCmdMaxOutput)
251+ }
252+ return out.buf.String(), nil
253+}
254+
255+// cappedBuffer keeps the first max bytes written and notes an overflow.
256+type cappedBuffer struct {
257+ buf bytes.Buffer
258+ max int
259+ over bool
260+}
261+
262+func (b *cappedBuffer) Write(p []byte) (int, error) {
263+ n := len(p) // report everything as written: a short write would abort the copy
264+ if room := b.max - b.buf.Len(); len(p) > room {
265+ b.over = true
266+ p = p[:room]
267+ }
268+ b.buf.Write(p)
269+ return n, nil
270+}
271+
272+var _ io.Writer = (*cappedBuffer)(nil)
A internal/app/cli_configfile_test.go +450 -0
@@ -0,0 +1,450 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "os"
9+ "path/filepath"
10+ "runtime"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+ "golang.org/x/term"
18+
19+ "bnema.dev/ermite-api/gen/ermite"
20+)
21+
22+// writeConfigFile writes the config file of env with the given mode.
23+func writeConfigFile(t *testing.T, env *cliEnv, content string, mode os.FileMode) string {
24+ t.Helper()
25+ path, err := env.configPath()
26+ require.NoError(t, err)
27+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
28+ require.NoError(t, os.WriteFile(path, []byte(content), mode))
29+ require.NoError(t, os.Chmod(path, mode)) // umask
30+ return path
31+}
32+
33+// tokenCmdStub makes runTokenCmd return out and err, and counts the runs.
34+func tokenCmdStub(env *cliEnv, out string, err error) *[]string {
35+ var ran []string
36+ env.runTokenCmd = func(_ context.Context, line string) (string, error) {
37+ ran = append(ran, line)
38+ return out, err
39+ }
40+ return &ran
41+}
42+
43+func listFake() *fakeAPI {
44+ return &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
45+ return &ermite.ListReposResponse{}, nil
46+ }}
47+}
48+
49+func lastAuth(f *fakeAPI) string {
50+ f.mu.Lock()
51+ defer f.mu.Unlock()
52+ if len(f.header) == 0 {
53+ return "<no call>"
54+ }
55+ return f.header[len(f.header)-1].Get("Authorization")
56+}
57+
58+func TestConfigFileSuppliesURLAndToken(t *testing.T) {
59+ f := listFake()
60+ url := f.serve(t)
61+ env := testEnv(t, nil)
62+ writeConfigFile(t, env, "# my server\nurl = "+url+"\ntoken.cmd = pass show ermite/token\n", 0o600)
63+ ran := tokenCmdStub(env, " ermite_fromcmd\n", nil)
64+
65+ runCLIWith(t, env, "", "repo", "list").ok(t)
66+ assert.Equal(t, "Bearer ermite_fromcmd", lastAuth(f))
67+ assert.Equal(t, []string{"pass show ermite/token"}, *ran)
68+}
69+
70+func TestConfigFileURLWithoutTokenCmdIsAnonymous(t *testing.T) {
71+ f := listFake()
72+ env := testEnv(t, nil)
73+ writeConfigFile(t, env, "url = "+f.serve(t)+"\n", 0o600)
74+
75+ runCLIWith(t, env, "", "repo", "list").ok(t)
76+ assert.Empty(t, lastAuth(f))
77+}
78+
79+func TestEnvironmentWinsOverConfigFile(t *testing.T) {
80+ fileSrv, envSrv := listFake(), listFake()
81+ env := testEnv(t, map[string]string{"ERMITE_URL": envSrv.serve(t), "ERMITE_TOKEN": "ermite_env"})
82+ writeConfigFile(t, env, "url = "+fileSrv.serve(t)+"\ntoken.cmd = echo nope\n", 0o600)
83+
84+ runCLIWith(t, env, "", "repo", "list").ok(t) // the default runTokenCmd of testEnv fails the test if called
85+ assert.Equal(t, "Bearer ermite_env", lastAuth(envSrv))
86+ calls, _ := fileSrv.snapshot()
87+ assert.Empty(t, calls, "the url of the file is not used when ERMITE_URL is set")
88+}
89+
90+func TestEnvURLDifferentFromFileKeepsTokenCmdOut(t *testing.T) {
91+ f := listFake()
92+ tests := []struct {
93+ name string
94+ envURL string // "" means the fake server
95+ file string // the content of the config file
96+ wantErr bool
97+ }{
98+ {name: "other url in the file", file: "url = http://file.invalid\ntoken.cmd = get-token\n"},
99+ {name: "file without url", file: "token.cmd = get-token\n"},
100+ {name: "invalid ERMITE_URL", envURL: "nope", file: "url = http://file.invalid\ntoken.cmd = get-token\n", wantErr: true},
101+ }
102+ for _, tt := range tests {
103+ t.Run(tt.name, func(t *testing.T) {
104+ envURL := tt.envURL
105+ if envURL == "" {
106+ envURL = f.serve(t)
107+ }
108+ env := testEnv(t, map[string]string{"ERMITE_URL": envURL})
109+ writeConfigFile(t, env, tt.file, 0o600)
110+ ran := tokenCmdStub(env, "ermite_cmd", nil)
111+
112+ res := runCLIWith(t, env, "", "repo", "list")
113+ assert.Empty(t, *ran, "the token of the file's server must not go to another server")
114+ if tt.wantErr {
115+ require.Error(t, res.err)
116+ return
117+ }
118+ require.NoError(t, res.err)
119+ assert.Empty(t, lastAuth(f))
120+ assert.Equal(t, 1, strings.Count(res.stderr, "token.cmd not run"), res.stderr)
121+ assert.Contains(t, res.stderr, "ERMITE_TOKEN")
122+ })
123+ }
124+}
125+
126+func TestEnvURLSameAsFileUsesTokenCmd(t *testing.T) {
127+ f := listFake()
128+ url := f.serve(t)
129+ // the same address, written differently
130+ env := testEnv(t, map[string]string{"ERMITE_URL": " " + strings.ToUpper(url[:4]) + url[4:] + "/ "})
131+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
132+ ran := tokenCmdStub(env, "ermite_cmd", nil)
133+
134+ res := runCLIWith(t, env, "", "repo", "list").ok(t)
135+ assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
136+ assert.Len(t, *ran, 1)
137+ assert.NotContains(t, res.stderr, "token.cmd")
138+}
139+
140+func TestTokenCmdNotRunWhenEnvTokenSet(t *testing.T) {
141+ f := listFake()
142+ env := testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"})
143+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o666) // even an unsafe file is not an issue
144+ ran := tokenCmdStub(env, "other", nil)
145+
146+ runCLIWith(t, env, "", "repo", "list").ok(t)
147+ assert.Empty(t, *ran)
148+ assert.Equal(t, "Bearer ermite_env", lastAuth(f))
149+}
150+
151+func TestTokenCmdNotRunForVersionNorCompletion(t *testing.T) {
152+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
153+ return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil
154+ }}
155+ url := f.serve(t)
156+
157+ for name, args := range map[string][]string{
158+ "version": {"version"},
159+ "version --json": {"version", "--json"},
160+ "completion": {"completion", "bash"},
161+ } {
162+ t.Run(name, func(t *testing.T) {
163+ env := testEnv(t, nil)
164+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
165+ ran := tokenCmdStub(env, "", errors.New("must not run"))
166+ runCLIWith(t, env, "", args...).ok(t)
167+ assert.Empty(t, *ran)
168+ })
169+ }
170+
171+ t.Run("version without any url", func(t *testing.T) {
172+ env := testEnv(t, nil)
173+ writeConfigFile(t, env, "token.cmd = get-token\n", 0o600)
174+ ran := tokenCmdStub(env, "", errors.New("must not run"))
175+ res := runCLIWith(t, env, "", "version").ok(t)
176+ assert.Equal(t, "ermite v0.0.1\n", res.stdout)
177+ assert.Empty(t, *ran)
178+ })
179+
180+ t.Run("version uses the url of the file and sends no token", func(t *testing.T) {
181+ env := testEnv(t, nil)
182+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
183+ tokenCmdStub(env, "ermite_cmd", nil)
184+ res := runCLIWith(t, env, "", "version").ok(t)
185+ assert.Contains(t, res.stdout, "v0.4.0 at "+url)
186+ assert.Empty(t, lastAuth(f))
187+ })
188+}
189+
190+func TestTokenCmdRunsOncePerProcess(t *testing.T) {
191+ f := listFake()
192+ env := testEnv(t, nil)
193+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
194+ ran := tokenCmdStub(env, "ermite_cmd", nil)
195+ root := newRootCmdWith(env)
196+ for range 2 {
197+ c, _, err := root.Find([]string{"repo", "list"})
198+ require.NoError(t, err)
199+ _, err = env.client(c)
200+ require.NoError(t, err)
201+ }
202+ assert.Len(t, *ran, 1)
203+}
204+
205+func TestTokenCmdRefusedWhenConfigFileIsWritableByOthers(t *testing.T) {
206+ if runtime.GOOS == "windows" {
207+ t.Skip("no Unix permission bits")
208+ }
209+ for _, mode := range []os.FileMode{0o666, 0o662, 0o620} {
210+ t.Run(mode.String(), func(t *testing.T) {
211+ env := testEnv(t, nil)
212+ path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", mode)
213+ ran := tokenCmdStub(env, "ermite_cmd", nil)
214+ res := runCLIWith(t, env, "", "repo", "list")
215+ require.Error(t, res.err)
216+ assert.Equal(t, "config file "+path+" is writable by others: token.cmd not run", res.err.Error())
217+ assert.Empty(t, *ran)
218+ })
219+ }
220+ t.Run("group readable is fine", func(t *testing.T) {
221+ f := listFake()
222+ env := testEnv(t, nil)
223+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o644)
224+ tokenCmdStub(env, "ermite_cmd", nil)
225+ runCLIWith(t, env, "", "repo", "list").ok(t)
226+ })
227+}
228+
229+func TestTokenCmdFailuresNeverShowOutput(t *testing.T) {
230+ const secret = "s3cret-token-value"
231+ tests := []struct {
232+ name string
233+ out string
234+ err error
235+ want string
236+ }{
237+ {"failure", secret, errors.New("exit status 3"), "token.cmd failed: exit status 3"},
238+ {"empty", " \n", nil, "token.cmd printed nothing"},
239+ {"multiline", secret + "\n" + secret + "\n", nil, "token.cmd printed more than one line"},
240+ {"crlf multiline", secret + "\r\n" + secret, nil, "token.cmd printed more than one line"},
241+ }
242+ for _, tt := range tests {
243+ t.Run(tt.name, func(t *testing.T) {
244+ env := testEnv(t, nil)
245+ writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600)
246+ tokenCmdStub(env, tt.out, tt.err)
247+ res := runCLIWith(t, env, "", "repo", "list")
248+ require.Error(t, res.err)
249+ assert.Equal(t, tt.want, res.err.Error())
250+ assert.NotContains(t, res.err.Error()+res.stdout+res.stderr, secret)
251+ })
252+ }
253+}
254+
255+func TestMissingURLIsReportedBeforeTokenCmdRuns(t *testing.T) {
256+ env := testEnv(t, nil)
257+ writeConfigFile(t, env, "token.cmd = get-token\n", 0o600)
258+ ran := tokenCmdStub(env, "ermite_cmd", nil)
259+ res := runCLIWith(t, env, "", "repo", "list")
260+ require.Error(t, res.err)
261+ assert.Contains(t, res.err.Error(), "ERMITE_URL is not set")
262+ assert.Contains(t, res.err.Error(), "ermite config set url")
263+ assert.Empty(t, *ran)
264+}
265+
266+func TestInvalidFileURLIsReported(t *testing.T) {
267+ env := testEnv(t, nil)
268+ path := writeConfigFile(t, env, "url = git.example.com\n", 0o600)
269+ res := runCLIWith(t, env, "", "repo", "list")
270+ require.Error(t, res.err)
271+ assert.Contains(t, res.err.Error(), "url in "+filepath.Dir(path)) // the file named
272+ assert.Contains(t, res.err.Error(), "ermite config set url")
273+ assert.Contains(t, res.err.Error(), "http")
274+}
275+
276+func TestConfigFileWarnings(t *testing.T) {
277+ f := listFake()
278+ env := testEnv(t, nil)
279+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ncolour = blue\n", 0o600)
280+ res := runCLIWith(t, env, "", "repo", "list").ok(t)
281+ assert.Equal(t, 1, strings.Count(res.stderr, "ermite: warning: config: "), res.stderr)
282+ assert.Contains(t, res.stderr, "colour")
283+ assert.NotContains(t, res.stderr, "blue", "values are never echoed")
284+}
285+
286+func TestCompletionStaysSilentAboutTheConfigFile(t *testing.T) {
287+ env := testEnv(t, nil)
288+ writeConfigFile(t, env, "colour = blue\nurl = nope\n", 0o600)
289+ res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
290+ assert.NotContains(t, res.stderr, "warning")
291+}
292+
293+func TestERMITE_CONFIGSelectsTheFile(t *testing.T) {
294+ f := listFake()
295+ path := filepath.Join(t.TempDir(), "other", "conf")
296+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
297+ require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\n"), 0o600))
298+
299+ env := testEnv(t, map[string]string{"ERMITE_CONFIG": path})
300+ env.configPath = nil // the default resolution, which reads ERMITE_CONFIG
301+ runCLIWith(t, env, "", "repo", "list").ok(t)
302+}
303+
304+func TestTokenCmdRunsThroughTheShell(t *testing.T) {
305+ if runtime.GOOS == "windows" {
306+ t.Skip("uses sh")
307+ }
308+ f := listFake()
309+ env := testEnv(t, nil)
310+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = printf secret\n", 0o600)
311+ env.runTokenCmd = runTokenShell // the real thing
312+
313+ runCLIWith(t, env, "", "repo", "list").ok(t)
314+ assert.Equal(t, "Bearer secret", lastAuth(f))
315+}
316+
317+func TestRunShell(t *testing.T) {
318+ if runtime.GOOS == "windows" {
319+ t.Skip("uses sh")
320+ }
321+ tests := []struct {
322+ name string
323+ line string
324+ want string // the output on success
325+ errHas string // the error on failure
326+ errNot string // what the error must not show
327+ wantErr bool
328+ }{
329+ {name: "exit status", line: "echo s3cret; exit 3", wantErr: true, errHas: "exit status 3", errNot: "s3cret"},
330+ {name: "too much output", line: "head -c 5000 /dev/zero | tr '\\0' a", wantErr: true, errHas: "more than 4096 bytes", errNot: "aaa"},
331+ {name: "output", line: "echo ermite_ok", want: "ermite_ok\n"},
332+ }
333+ for _, tt := range tests {
334+ t.Run(tt.name, func(t *testing.T) {
335+ out, err := runShell(t.Context(), tt.line, time.Minute)
336+ if !tt.wantErr {
337+ require.NoError(t, err)
338+ assert.Equal(t, tt.want, out)
339+ return
340+ }
341+ require.Error(t, err)
342+ assert.Contains(t, err.Error(), tt.errHas)
343+ assert.NotContains(t, err.Error(), tt.errNot)
344+ })
345+ }
346+}
347+
348+// requireNoTerminal skips a test of the non-terminal path when the tests run
349+// with a terminal as standard input.
350+func requireNoTerminal(t *testing.T) {
351+ t.Helper()
352+ if runtime.GOOS == "windows" {
353+ t.Skip("uses sh")
354+ }
355+ if term.IsTerminal(int(os.Stdin.Fd())) {
356+ t.Skip("standard input is a terminal")
357+ }
358+}
359+
360+func TestTokenCmdTimesOut(t *testing.T) {
361+ requireNoTerminal(t)
362+ f := listFake()
363+ env := testEnv(t, nil)
364+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = sleep 30; :\n", 0o600)
365+ env.runTokenCmd = func(ctx context.Context, line string) (string, error) {
366+ return runShell(ctx, line, 100*time.Millisecond)
367+ }
368+
369+ start := time.Now()
370+ res := runCLIWith(t, env, "", "repo", "list")
371+ require.Error(t, res.err)
372+ assert.Equal(t, "token.cmd failed: timed out after 100ms", res.err.Error())
373+ assert.Less(t, time.Since(start), 10*time.Second)
374+}
375+
376+func TestTokenCmdGetsNoInputWithoutATerminal(t *testing.T) {
377+ requireNoTerminal(t)
378+ out, err := runShell(t.Context(), "cat; echo done", time.Minute)
379+ require.NoError(t, err)
380+ assert.Equal(t, "done\n", out, "the command read nothing")
381+}
382+
383+func TestTokenCmdRefusedWhenDirectoryIsWritableByOthers(t *testing.T) {
384+ if runtime.GOOS == "windows" {
385+ t.Skip("no Unix permission bits")
386+ }
387+ env := testEnv(t, nil)
388+ path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600)
389+ dir := filepath.Dir(path)
390+ require.NoError(t, os.Chmod(dir, 0o777))
391+ ran := tokenCmdStub(env, "ermite_cmd", nil)
392+
393+ res := runCLIWith(t, env, "", "repo", "list")
394+ require.Error(t, res.err)
395+ assert.Equal(t, "config directory "+dir+" is writable by others: token.cmd not run", res.err.Error())
396+ assert.Empty(t, *ran)
397+
398+ t.Run("sticky directory is fine", func(t *testing.T) {
399+ require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky))
400+ f := listFake()
401+ env := testEnvAt(t, path)
402+ require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\ntoken.cmd = get-token\n"), 0o600))
403+ tokenCmdStub(env, "ermite_cmd", nil)
404+ runCLIWith(t, env, "", "repo", "list").ok(t)
405+ })
406+}
407+
408+func TestTokenCmdFollowsASymlinkedConfigFile(t *testing.T) {
409+ if runtime.GOOS == "windows" {
410+ t.Skip("symlinks")
411+ }
412+ f := listFake()
413+ env := testEnv(t, nil)
414+ real := writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
415+ link := filepath.Join(t.TempDir(), "dotfile-link")
416+ require.NoError(t, os.Symlink(real, link))
417+ tokenCmdStub(env, "ermite_cmd", nil)
418+ env.configPath = func() (string, error) { return link, nil }
419+ runCLIWith(t, env, "", "repo", "list").ok(t)
420+ assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
421+}
422+
423+func TestVersionWarnsAboutAnUnreadableConfigFile(t *testing.T) {
424+ env := testEnv(t, nil)
425+ path := writeConfigFile(t, env, "", 0o600)
426+ require.NoError(t, os.Remove(path))
427+ require.NoError(t, os.Mkdir(path, 0o700))
428+ res := runCLIWith(t, env, "", "version").ok(t)
429+ assert.Equal(t, "ermite v0.0.1\n", res.stdout)
430+ assert.Contains(t, res.stderr, "ermite: warning: config:")
431+}
432+
433+func TestCompletionNeverRunsTokenCmd(t *testing.T) {
434+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
435+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
436+ }}
437+ env := testEnv(t, nil)
438+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
439+ ran := tokenCmdStub(env, "ermite_cmd", nil)
440+ res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
441+ assert.Empty(t, *ran)
442+ assert.Contains(t, res.stdout, "alpha")
443+ assert.Empty(t, lastAuth(f))
444+
445+ // with ERMITE_TOKEN set the completion is authenticated
446+ env = testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"})
447+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
448+ runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
449+ assert.Equal(t, "Bearer ermite_env", lastAuth(f))
450+}
@@ -0,0 +1,450 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "os"
9+ "path/filepath"
10+ "runtime"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+ "golang.org/x/term"
18+
19+ "bnema.dev/ermite-api/gen/ermite"
20+)
21+
22+// writeConfigFile writes the config file of env with the given mode.
23+func writeConfigFile(t *testing.T, env *cliEnv, content string, mode os.FileMode) string {
24+ t.Helper()
25+ path, err := env.configPath()
26+ require.NoError(t, err)
27+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
28+ require.NoError(t, os.WriteFile(path, []byte(content), mode))
29+ require.NoError(t, os.Chmod(path, mode)) // umask
30+ return path
31+}
32+
33+// tokenCmdStub makes runTokenCmd return out and err, and counts the runs.
34+func tokenCmdStub(env *cliEnv, out string, err error) *[]string {
35+ var ran []string
36+ env.runTokenCmd = func(_ context.Context, line string) (string, error) {
37+ ran = append(ran, line)
38+ return out, err
39+ }
40+ return &ran
41+}
42+
43+func listFake() *fakeAPI {
44+ return &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
45+ return &ermite.ListReposResponse{}, nil
46+ }}
47+}
48+
49+func lastAuth(f *fakeAPI) string {
50+ f.mu.Lock()
51+ defer f.mu.Unlock()
52+ if len(f.header) == 0 {
53+ return "<no call>"
54+ }
55+ return f.header[len(f.header)-1].Get("Authorization")
56+}
57+
58+func TestConfigFileSuppliesURLAndToken(t *testing.T) {
59+ f := listFake()
60+ url := f.serve(t)
61+ env := testEnv(t, nil)
62+ writeConfigFile(t, env, "# my server\nurl = "+url+"\ntoken.cmd = pass show ermite/token\n", 0o600)
63+ ran := tokenCmdStub(env, " ermite_fromcmd\n", nil)
64+
65+ runCLIWith(t, env, "", "repo", "list").ok(t)
66+ assert.Equal(t, "Bearer ermite_fromcmd", lastAuth(f))
67+ assert.Equal(t, []string{"pass show ermite/token"}, *ran)
68+}
69+
70+func TestConfigFileURLWithoutTokenCmdIsAnonymous(t *testing.T) {
71+ f := listFake()
72+ env := testEnv(t, nil)
73+ writeConfigFile(t, env, "url = "+f.serve(t)+"\n", 0o600)
74+
75+ runCLIWith(t, env, "", "repo", "list").ok(t)
76+ assert.Empty(t, lastAuth(f))
77+}
78+
79+func TestEnvironmentWinsOverConfigFile(t *testing.T) {
80+ fileSrv, envSrv := listFake(), listFake()
81+ env := testEnv(t, map[string]string{"ERMITE_URL": envSrv.serve(t), "ERMITE_TOKEN": "ermite_env"})
82+ writeConfigFile(t, env, "url = "+fileSrv.serve(t)+"\ntoken.cmd = echo nope\n", 0o600)
83+
84+ runCLIWith(t, env, "", "repo", "list").ok(t) // the default runTokenCmd of testEnv fails the test if called
85+ assert.Equal(t, "Bearer ermite_env", lastAuth(envSrv))
86+ calls, _ := fileSrv.snapshot()
87+ assert.Empty(t, calls, "the url of the file is not used when ERMITE_URL is set")
88+}
89+
90+func TestEnvURLDifferentFromFileKeepsTokenCmdOut(t *testing.T) {
91+ f := listFake()
92+ tests := []struct {
93+ name string
94+ envURL string // "" means the fake server
95+ file string // the content of the config file
96+ wantErr bool
97+ }{
98+ {name: "other url in the file", file: "url = http://file.invalid\ntoken.cmd = get-token\n"},
99+ {name: "file without url", file: "token.cmd = get-token\n"},
100+ {name: "invalid ERMITE_URL", envURL: "nope", file: "url = http://file.invalid\ntoken.cmd = get-token\n", wantErr: true},
101+ }
102+ for _, tt := range tests {
103+ t.Run(tt.name, func(t *testing.T) {
104+ envURL := tt.envURL
105+ if envURL == "" {
106+ envURL = f.serve(t)
107+ }
108+ env := testEnv(t, map[string]string{"ERMITE_URL": envURL})
109+ writeConfigFile(t, env, tt.file, 0o600)
110+ ran := tokenCmdStub(env, "ermite_cmd", nil)
111+
112+ res := runCLIWith(t, env, "", "repo", "list")
113+ assert.Empty(t, *ran, "the token of the file's server must not go to another server")
114+ if tt.wantErr {
115+ require.Error(t, res.err)
116+ return
117+ }
118+ require.NoError(t, res.err)
119+ assert.Empty(t, lastAuth(f))
120+ assert.Equal(t, 1, strings.Count(res.stderr, "token.cmd not run"), res.stderr)
121+ assert.Contains(t, res.stderr, "ERMITE_TOKEN")
122+ })
123+ }
124+}
125+
126+func TestEnvURLSameAsFileUsesTokenCmd(t *testing.T) {
127+ f := listFake()
128+ url := f.serve(t)
129+ // the same address, written differently
130+ env := testEnv(t, map[string]string{"ERMITE_URL": " " + strings.ToUpper(url[:4]) + url[4:] + "/ "})
131+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
132+ ran := tokenCmdStub(env, "ermite_cmd", nil)
133+
134+ res := runCLIWith(t, env, "", "repo", "list").ok(t)
135+ assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
136+ assert.Len(t, *ran, 1)
137+ assert.NotContains(t, res.stderr, "token.cmd")
138+}
139+
140+func TestTokenCmdNotRunWhenEnvTokenSet(t *testing.T) {
141+ f := listFake()
142+ env := testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"})
143+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o666) // even an unsafe file is not an issue
144+ ran := tokenCmdStub(env, "other", nil)
145+
146+ runCLIWith(t, env, "", "repo", "list").ok(t)
147+ assert.Empty(t, *ran)
148+ assert.Equal(t, "Bearer ermite_env", lastAuth(f))
149+}
150+
151+func TestTokenCmdNotRunForVersionNorCompletion(t *testing.T) {
152+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
153+ return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil
154+ }}
155+ url := f.serve(t)
156+
157+ for name, args := range map[string][]string{
158+ "version": {"version"},
159+ "version --json": {"version", "--json"},
160+ "completion": {"completion", "bash"},
161+ } {
162+ t.Run(name, func(t *testing.T) {
163+ env := testEnv(t, nil)
164+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
165+ ran := tokenCmdStub(env, "", errors.New("must not run"))
166+ runCLIWith(t, env, "", args...).ok(t)
167+ assert.Empty(t, *ran)
168+ })
169+ }
170+
171+ t.Run("version without any url", func(t *testing.T) {
172+ env := testEnv(t, nil)
173+ writeConfigFile(t, env, "token.cmd = get-token\n", 0o600)
174+ ran := tokenCmdStub(env, "", errors.New("must not run"))
175+ res := runCLIWith(t, env, "", "version").ok(t)
176+ assert.Equal(t, "ermite v0.0.1\n", res.stdout)
177+ assert.Empty(t, *ran)
178+ })
179+
180+ t.Run("version uses the url of the file and sends no token", func(t *testing.T) {
181+ env := testEnv(t, nil)
182+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
183+ tokenCmdStub(env, "ermite_cmd", nil)
184+ res := runCLIWith(t, env, "", "version").ok(t)
185+ assert.Contains(t, res.stdout, "v0.4.0 at "+url)
186+ assert.Empty(t, lastAuth(f))
187+ })
188+}
189+
190+func TestTokenCmdRunsOncePerProcess(t *testing.T) {
191+ f := listFake()
192+ env := testEnv(t, nil)
193+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
194+ ran := tokenCmdStub(env, "ermite_cmd", nil)
195+ root := newRootCmdWith(env)
196+ for range 2 {
197+ c, _, err := root.Find([]string{"repo", "list"})
198+ require.NoError(t, err)
199+ _, err = env.client(c)
200+ require.NoError(t, err)
201+ }
202+ assert.Len(t, *ran, 1)
203+}
204+
205+func TestTokenCmdRefusedWhenConfigFileIsWritableByOthers(t *testing.T) {
206+ if runtime.GOOS == "windows" {
207+ t.Skip("no Unix permission bits")
208+ }
209+ for _, mode := range []os.FileMode{0o666, 0o662, 0o620} {
210+ t.Run(mode.String(), func(t *testing.T) {
211+ env := testEnv(t, nil)
212+ path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", mode)
213+ ran := tokenCmdStub(env, "ermite_cmd", nil)
214+ res := runCLIWith(t, env, "", "repo", "list")
215+ require.Error(t, res.err)
216+ assert.Equal(t, "config file "+path+" is writable by others: token.cmd not run", res.err.Error())
217+ assert.Empty(t, *ran)
218+ })
219+ }
220+ t.Run("group readable is fine", func(t *testing.T) {
221+ f := listFake()
222+ env := testEnv(t, nil)
223+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o644)
224+ tokenCmdStub(env, "ermite_cmd", nil)
225+ runCLIWith(t, env, "", "repo", "list").ok(t)
226+ })
227+}
228+
229+func TestTokenCmdFailuresNeverShowOutput(t *testing.T) {
230+ const secret = "s3cret-token-value"
231+ tests := []struct {
232+ name string
233+ out string
234+ err error
235+ want string
236+ }{
237+ {"failure", secret, errors.New("exit status 3"), "token.cmd failed: exit status 3"},
238+ {"empty", " \n", nil, "token.cmd printed nothing"},
239+ {"multiline", secret + "\n" + secret + "\n", nil, "token.cmd printed more than one line"},
240+ {"crlf multiline", secret + "\r\n" + secret, nil, "token.cmd printed more than one line"},
241+ }
242+ for _, tt := range tests {
243+ t.Run(tt.name, func(t *testing.T) {
244+ env := testEnv(t, nil)
245+ writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600)
246+ tokenCmdStub(env, tt.out, tt.err)
247+ res := runCLIWith(t, env, "", "repo", "list")
248+ require.Error(t, res.err)
249+ assert.Equal(t, tt.want, res.err.Error())
250+ assert.NotContains(t, res.err.Error()+res.stdout+res.stderr, secret)
251+ })
252+ }
253+}
254+
255+func TestMissingURLIsReportedBeforeTokenCmdRuns(t *testing.T) {
256+ env := testEnv(t, nil)
257+ writeConfigFile(t, env, "token.cmd = get-token\n", 0o600)
258+ ran := tokenCmdStub(env, "ermite_cmd", nil)
259+ res := runCLIWith(t, env, "", "repo", "list")
260+ require.Error(t, res.err)
261+ assert.Contains(t, res.err.Error(), "ERMITE_URL is not set")
262+ assert.Contains(t, res.err.Error(), "ermite config set url")
263+ assert.Empty(t, *ran)
264+}
265+
266+func TestInvalidFileURLIsReported(t *testing.T) {
267+ env := testEnv(t, nil)
268+ path := writeConfigFile(t, env, "url = git.example.com\n", 0o600)
269+ res := runCLIWith(t, env, "", "repo", "list")
270+ require.Error(t, res.err)
271+ assert.Contains(t, res.err.Error(), "url in "+filepath.Dir(path)) // the file named
272+ assert.Contains(t, res.err.Error(), "ermite config set url")
273+ assert.Contains(t, res.err.Error(), "http")
274+}
275+
276+func TestConfigFileWarnings(t *testing.T) {
277+ f := listFake()
278+ env := testEnv(t, nil)
279+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ncolour = blue\n", 0o600)
280+ res := runCLIWith(t, env, "", "repo", "list").ok(t)
281+ assert.Equal(t, 1, strings.Count(res.stderr, "ermite: warning: config: "), res.stderr)
282+ assert.Contains(t, res.stderr, "colour")
283+ assert.NotContains(t, res.stderr, "blue", "values are never echoed")
284+}
285+
286+func TestCompletionStaysSilentAboutTheConfigFile(t *testing.T) {
287+ env := testEnv(t, nil)
288+ writeConfigFile(t, env, "colour = blue\nurl = nope\n", 0o600)
289+ res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
290+ assert.NotContains(t, res.stderr, "warning")
291+}
292+
293+func TestERMITE_CONFIGSelectsTheFile(t *testing.T) {
294+ f := listFake()
295+ path := filepath.Join(t.TempDir(), "other", "conf")
296+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
297+ require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\n"), 0o600))
298+
299+ env := testEnv(t, map[string]string{"ERMITE_CONFIG": path})
300+ env.configPath = nil // the default resolution, which reads ERMITE_CONFIG
301+ runCLIWith(t, env, "", "repo", "list").ok(t)
302+}
303+
304+func TestTokenCmdRunsThroughTheShell(t *testing.T) {
305+ if runtime.GOOS == "windows" {
306+ t.Skip("uses sh")
307+ }
308+ f := listFake()
309+ env := testEnv(t, nil)
310+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = printf secret\n", 0o600)
311+ env.runTokenCmd = runTokenShell // the real thing
312+
313+ runCLIWith(t, env, "", "repo", "list").ok(t)
314+ assert.Equal(t, "Bearer secret", lastAuth(f))
315+}
316+
317+func TestRunShell(t *testing.T) {
318+ if runtime.GOOS == "windows" {
319+ t.Skip("uses sh")
320+ }
321+ tests := []struct {
322+ name string
323+ line string
324+ want string // the output on success
325+ errHas string // the error on failure
326+ errNot string // what the error must not show
327+ wantErr bool
328+ }{
329+ {name: "exit status", line: "echo s3cret; exit 3", wantErr: true, errHas: "exit status 3", errNot: "s3cret"},
330+ {name: "too much output", line: "head -c 5000 /dev/zero | tr '\\0' a", wantErr: true, errHas: "more than 4096 bytes", errNot: "aaa"},
331+ {name: "output", line: "echo ermite_ok", want: "ermite_ok\n"},
332+ }
333+ for _, tt := range tests {
334+ t.Run(tt.name, func(t *testing.T) {
335+ out, err := runShell(t.Context(), tt.line, time.Minute)
336+ if !tt.wantErr {
337+ require.NoError(t, err)
338+ assert.Equal(t, tt.want, out)
339+ return
340+ }
341+ require.Error(t, err)
342+ assert.Contains(t, err.Error(), tt.errHas)
343+ assert.NotContains(t, err.Error(), tt.errNot)
344+ })
345+ }
346+}
347+
348+// requireNoTerminal skips a test of the non-terminal path when the tests run
349+// with a terminal as standard input.
350+func requireNoTerminal(t *testing.T) {
351+ t.Helper()
352+ if runtime.GOOS == "windows" {
353+ t.Skip("uses sh")
354+ }
355+ if term.IsTerminal(int(os.Stdin.Fd())) {
356+ t.Skip("standard input is a terminal")
357+ }
358+}
359+
360+func TestTokenCmdTimesOut(t *testing.T) {
361+ requireNoTerminal(t)
362+ f := listFake()
363+ env := testEnv(t, nil)
364+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = sleep 30; :\n", 0o600)
365+ env.runTokenCmd = func(ctx context.Context, line string) (string, error) {
366+ return runShell(ctx, line, 100*time.Millisecond)
367+ }
368+
369+ start := time.Now()
370+ res := runCLIWith(t, env, "", "repo", "list")
371+ require.Error(t, res.err)
372+ assert.Equal(t, "token.cmd failed: timed out after 100ms", res.err.Error())
373+ assert.Less(t, time.Since(start), 10*time.Second)
374+}
375+
376+func TestTokenCmdGetsNoInputWithoutATerminal(t *testing.T) {
377+ requireNoTerminal(t)
378+ out, err := runShell(t.Context(), "cat; echo done", time.Minute)
379+ require.NoError(t, err)
380+ assert.Equal(t, "done\n", out, "the command read nothing")
381+}
382+
383+func TestTokenCmdRefusedWhenDirectoryIsWritableByOthers(t *testing.T) {
384+ if runtime.GOOS == "windows" {
385+ t.Skip("no Unix permission bits")
386+ }
387+ env := testEnv(t, nil)
388+ path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600)
389+ dir := filepath.Dir(path)
390+ require.NoError(t, os.Chmod(dir, 0o777))
391+ ran := tokenCmdStub(env, "ermite_cmd", nil)
392+
393+ res := runCLIWith(t, env, "", "repo", "list")
394+ require.Error(t, res.err)
395+ assert.Equal(t, "config directory "+dir+" is writable by others: token.cmd not run", res.err.Error())
396+ assert.Empty(t, *ran)
397+
398+ t.Run("sticky directory is fine", func(t *testing.T) {
399+ require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky))
400+ f := listFake()
401+ env := testEnvAt(t, path)
402+ require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\ntoken.cmd = get-token\n"), 0o600))
403+ tokenCmdStub(env, "ermite_cmd", nil)
404+ runCLIWith(t, env, "", "repo", "list").ok(t)
405+ })
406+}
407+
408+func TestTokenCmdFollowsASymlinkedConfigFile(t *testing.T) {
409+ if runtime.GOOS == "windows" {
410+ t.Skip("symlinks")
411+ }
412+ f := listFake()
413+ env := testEnv(t, nil)
414+ real := writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
415+ link := filepath.Join(t.TempDir(), "dotfile-link")
416+ require.NoError(t, os.Symlink(real, link))
417+ tokenCmdStub(env, "ermite_cmd", nil)
418+ env.configPath = func() (string, error) { return link, nil }
419+ runCLIWith(t, env, "", "repo", "list").ok(t)
420+ assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
421+}
422+
423+func TestVersionWarnsAboutAnUnreadableConfigFile(t *testing.T) {
424+ env := testEnv(t, nil)
425+ path := writeConfigFile(t, env, "", 0o600)
426+ require.NoError(t, os.Remove(path))
427+ require.NoError(t, os.Mkdir(path, 0o700))
428+ res := runCLIWith(t, env, "", "version").ok(t)
429+ assert.Equal(t, "ermite v0.0.1\n", res.stdout)
430+ assert.Contains(t, res.stderr, "ermite: warning: config:")
431+}
432+
433+func TestCompletionNeverRunsTokenCmd(t *testing.T) {
434+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
435+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
436+ }}
437+ env := testEnv(t, nil)
438+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
439+ ran := tokenCmdStub(env, "ermite_cmd", nil)
440+ res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
441+ assert.Empty(t, *ran)
442+ assert.Contains(t, res.stdout, "alpha")
443+ assert.Empty(t, lastAuth(f))
444+
445+ // with ERMITE_TOKEN set the completion is authenticated
446+ env = testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"})
447+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
448+ runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
449+ assert.Equal(t, "Bearer ermite_env", lastAuth(f))
450+}
M internal/app/cli_fake_test.go +31 -6
@@ -5,8 +5,10 @@ package app
55 import (
66 "bytes"
77 "context"
8+ "errors"
89 "net/http"
910 "net/http/httptest"
11+ "path/filepath"
1012 "strings"
1113 "sync"
1214 "testing"
@@ -29,6 +31,7 @@ type fakeAPI struct {
2931 ermiteconnect.UnimplementedServerServiceHandler
3032 ermiteconnect.UnimplementedEventServiceHandler
3133 ermiteconnect.UnimplementedMergeRequestServiceHandler
34+ ermiteconnect.UnimplementedRunServiceHandler
3235
3336 mu sync.Mutex
3437 calls []string // procedure names, in order
@@ -160,6 +163,8 @@ func (f *fakeAPI) serve(t *testing.T) string {
160163 mount(p, h)
161164 p, h = ermiteconnect.NewMergeRequestServiceHandler(f)
162165 mount(p, h)
166+ p, h = ermiteconnect.NewRunServiceHandler(f)
167+ mount(p, h)
163168 srv := httptest.NewServer(mux)
164169 t.Cleanup(srv.Close)
165170 return srv.URL
@@ -173,15 +178,35 @@ type result struct {
173178 err error
174179 }
175180
181+// testEnv is a cliEnv with the given variables, the fixed clock, and a config
182+// file path inside a fresh temp dir (no file yet) so that a test never reads
183+// the developer's own ~/.config/ermite/config. token.cmd must not run unless
184+// the test sets runTokenCmd.
185+func testEnv(t *testing.T, vars map[string]string) *cliEnv {
186+ t.Helper()
187+ path := filepath.Join(t.TempDir(), "ermite", "config")
188+ return &cliEnv{
189+ getenv: func(k string) string { return vars[k] },
190+ now: func() time.Time { return testNow },
191+ version: "v0.0.1",
192+ sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() },
193+ configPath: func() (string, error) { return path, nil },
194+ runTokenCmd: func(context.Context, string) (string, error) {
195+ t.Error("token.cmd must not run")
196+ return "", errors.New("token.cmd must not run")
197+ },
198+ }
199+}
200+
176201 // runCLI runs the command tree in process with the given environment.
177202 func runCLI(t *testing.T, vars map[string]string, stdin string, args ...string) result {
178203 t.Helper()
179- env := &cliEnv{
180- getenv: func(k string) string { return vars[k] },
181- now: func() time.Time { return testNow },
182- version: "v0.0.1",
183- sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() },
184- }
204+ return runCLIWith(t, testEnv(t, vars), stdin, args...)
205+}
206+
207+// runCLIWith runs the command tree in process with env.
208+func runCLIWith(t *testing.T, env *cliEnv, stdin string, args ...string) result {
209+ t.Helper()
185210 root := newRootCmdWith(env)
186211 var out, errb bytes.Buffer
187212 root.SetOut(&out)
@@ -5,8 +5,10 @@ package app
5 import (5 import (
6 "bytes"6 "bytes"
7 "context"7 "context"
8+ "errors"
8 "net/http"9 "net/http"
9 "net/http/httptest"10 "net/http/httptest"
11+ "path/filepath"
10 "strings"12 "strings"
11 "sync"13 "sync"
12 "testing"14 "testing"
@@ -29,6 +31,7 @@ type fakeAPI struct {
29 ermiteconnect.UnimplementedServerServiceHandler31 ermiteconnect.UnimplementedServerServiceHandler
30 ermiteconnect.UnimplementedEventServiceHandler32 ermiteconnect.UnimplementedEventServiceHandler
31 ermiteconnect.UnimplementedMergeRequestServiceHandler33 ermiteconnect.UnimplementedMergeRequestServiceHandler
34+ ermiteconnect.UnimplementedRunServiceHandler
32 35
33 mu sync.Mutex36 mu sync.Mutex
34 calls []string // procedure names, in order37 calls []string // procedure names, in order
@@ -160,6 +163,8 @@ func (f *fakeAPI) serve(t *testing.T) string {
160 mount(p, h)163 mount(p, h)
161 p, h = ermiteconnect.NewMergeRequestServiceHandler(f)164 p, h = ermiteconnect.NewMergeRequestServiceHandler(f)
162 mount(p, h)165 mount(p, h)
166+ p, h = ermiteconnect.NewRunServiceHandler(f)
167+ mount(p, h)
163 srv := httptest.NewServer(mux)168 srv := httptest.NewServer(mux)
164 t.Cleanup(srv.Close)169 t.Cleanup(srv.Close)
165 return srv.URL170 return srv.URL
@@ -173,15 +178,35 @@ type result struct {
173 err error178 err error
174 }179 }
175 180
181+// testEnv is a cliEnv with the given variables, the fixed clock, and a config
182+// file path inside a fresh temp dir (no file yet) so that a test never reads
183+// the developer's own ~/.config/ermite/config. token.cmd must not run unless
184+// the test sets runTokenCmd.
185+func testEnv(t *testing.T, vars map[string]string) *cliEnv {
186+ t.Helper()
187+ path := filepath.Join(t.TempDir(), "ermite", "config")
188+ return &cliEnv{
189+ getenv: func(k string) string { return vars[k] },
190+ now: func() time.Time { return testNow },
191+ version: "v0.0.1",
192+ sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() },
193+ configPath: func() (string, error) { return path, nil },
194+ runTokenCmd: func(context.Context, string) (string, error) {
195+ t.Error("token.cmd must not run")
196+ return "", errors.New("token.cmd must not run")
197+ },
198+ }
199+}
200+
176 // runCLI runs the command tree in process with the given environment.201 // runCLI runs the command tree in process with the given environment.
177 func runCLI(t *testing.T, vars map[string]string, stdin string, args ...string) result {202 func runCLI(t *testing.T, vars map[string]string, stdin string, args ...string) result {
178 t.Helper()203 t.Helper()
179- env := &cliEnv{204+ return runCLIWith(t, testEnv(t, vars), stdin, args...)
180- getenv: func(k string) string { return vars[k] },205+}
181- now: func() time.Time { return testNow },206+
182- version: "v0.0.1",207+// runCLIWith runs the command tree in process with env.
183- sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() },208+func runCLIWith(t *testing.T, env *cliEnv, stdin string, args ...string) result {
184- }209+ t.Helper()
185 root := newRootCmdWith(env)210 root := newRootCmdWith(env)
186 var out, errb bytes.Buffer211 var out, errb bytes.Buffer
187 root.SetOut(&out)212 root.SetOut(&out)
M internal/app/cli_mr.go +8 -8
@@ -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 }
@@ -265,7 +265,7 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command {
265265 Repo: args[0], Title: title, Description: desc,
266266 SourceBranch: trimBranch(source), TargetBranch: trimBranch(target),
267267 }
268- c, err := env.client(cmd, true)
268+ c, err := env.client(cmd)
269269 if err != nil {
270270 return err
271271 }
@@ -310,7 +310,7 @@ func newMRListCmd(env *cliEnv) *cobra.Command {
310310 if err != nil {
311311 return err
312312 }
313- c, err := env.client(cmd, true)
313+ c, err := env.client(cmd)
314314 if err != nil {
315315 return err
316316 }
@@ -474,7 +474,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
474474 if err != nil {
475475 return err
476476 }
477- c, err := env.client(cmd, true)
477+ c, err := env.client(cmd)
478478 if err != nil {
479479 return err
480480 }
@@ -687,7 +687,7 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
687687 case line != 0 || cmd.Flags().Changed("side"):
688688 return errors.New("--line and --side need --path")
689689 }
690- c, err := env.client(cmd, true)
690+ c, err := env.client(cmd)
691691 if err != nil {
692692 return err
693693 }
@@ -722,7 +722,7 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command {
722722 return err
723723 }
724724 id := strings.TrimPrefix(strings.TrimSpace(args[2]), "#")
725- c, err := env.client(cmd, true)
725+ c, err := env.client(cmd)
726726 if err != nil {
727727 return err
728728 }
@@ -781,7 +781,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
781781 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
782782 "so that only the commit you reviewed is merged", args[0], n)
783783 }
784- c, err := env.client(cmd, true)
784+ c, err := env.client(cmd)
785785 if err != nil {
786786 return err
787787 }
@@ -830,7 +830,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
830830 if err != nil {
831831 return err
832832 }
833- c, err := env.client(cmd, true)
833+ c, err := env.client(cmd)
834834 if err != nil {
835835 return err
836836 }
@@ -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 }
@@ -265,7 +265,7 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command {
265 Repo: args[0], Title: title, Description: desc,265 Repo: args[0], Title: title, Description: desc,
266 SourceBranch: trimBranch(source), TargetBranch: trimBranch(target),266 SourceBranch: trimBranch(source), TargetBranch: trimBranch(target),
267 }267 }
268- c, err := env.client(cmd, true)268+ c, err := env.client(cmd)
269 if err != nil {269 if err != nil {
270 return err270 return err
271 }271 }
@@ -310,7 +310,7 @@ func newMRListCmd(env *cliEnv) *cobra.Command {
310 if err != nil {310 if err != nil {
311 return err311 return err
312 }312 }
313- c, err := env.client(cmd, true)313+ c, err := env.client(cmd)
314 if err != nil {314 if err != nil {
315 return err315 return err
316 }316 }
@@ -474,7 +474,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
474 if err != nil {474 if err != nil {
475 return err475 return err
476 }476 }
477- c, err := env.client(cmd, true)477+ c, err := env.client(cmd)
478 if err != nil {478 if err != nil {
479 return err479 return err
480 }480 }
@@ -687,7 +687,7 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
687 case line != 0 || cmd.Flags().Changed("side"):687 case line != 0 || cmd.Flags().Changed("side"):
688 return errors.New("--line and --side need --path")688 return errors.New("--line and --side need --path")
689 }689 }
690- c, err := env.client(cmd, true)690+ c, err := env.client(cmd)
691 if err != nil {691 if err != nil {
692 return err692 return err
693 }693 }
@@ -722,7 +722,7 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command {
722 return err722 return err
723 }723 }
724 id := strings.TrimPrefix(strings.TrimSpace(args[2]), "#")724 id := strings.TrimPrefix(strings.TrimSpace(args[2]), "#")
725- c, err := env.client(cmd, true)725+ c, err := env.client(cmd)
726 if err != nil {726 if err != nil {
727 return err727 return err
728 }728 }
@@ -781,7 +781,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
781 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+781 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
782 "so that only the commit you reviewed is merged", args[0], n)782 "so that only the commit you reviewed is merged", args[0], n)
783 }783 }
784- c, err := env.client(cmd, true)784+ c, err := env.client(cmd)
785 if err != nil {785 if err != nil {
786 return err786 return err
787 }787 }
@@ -830,7 +830,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
830 if err != nil {830 if err != nil {
831 return err831 return err
832 }832 }
833- c, err := env.client(cmd, true)833+ c, err := env.client(cmd)
834 if err != nil {834 if err != nil {
835 return err835 return err
836 }836 }
M internal/app/cli_repo.go +6 -6
@@ -86,7 +86,7 @@ func newRepoCmd(env *cliEnv) *cobra.Command {
8686 repo := &cobra.Command{
8787 Use: "repo",
8888 Short: "Manage repositories on a remote server",
89- Long: "Manage repositories on the server at ERMITE_URL, authenticated with ERMITE_TOKEN.",
89+ Long: "Manage repositories on the configured server (ERMITE_URL or the config file, see `ermite config`), authenticated with ERMITE_TOKEN or the token command of the config file.",
9090 }
9191 repo.AddCommand(
9292 newRepoListCmd(env), newRepoShowCmd(env), newRepoCreateCmd(env),
@@ -146,7 +146,7 @@ func newRepoListCmd(env *cliEnv) *cobra.Command {
146146 }
147147 asJSON := addJSONFlag(cmd)
148148 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
149- c, err := env.client(cmd, true)
149+ c, err := env.client(cmd)
150150 if err != nil {
151151 return err
152152 }
@@ -205,7 +205,7 @@ func newRepoShowCmd(env *cliEnv) *cobra.Command {
205205 }
206206 asJSON := addJSONFlag(cmd)
207207 cmd.RunE = func(cmd *cobra.Command, args []string) error {
208- c, err := env.client(cmd, true)
208+ c, err := env.client(cmd)
209209 if err != nil {
210210 return err
211211 }
@@ -239,7 +239,7 @@ func newRepoCreateCmd(env *cliEnv) *cobra.Command {
239239 if err != nil {
240240 return err
241241 }
242- c, err := env.client(cmd, true)
242+ c, err := env.client(cmd)
243243 if err != nil {
244244 return err
245245 }
@@ -295,7 +295,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
295295 if err != nil {
296296 return err
297297 }
298- c, err := env.client(cmd, true)
298+ c, err := env.client(cmd)
299299 if err != nil {
300300 return err
301301 }
@@ -377,7 +377,7 @@ func newRepoDeleteCmd(env *cliEnv) *cobra.Command {
377377 cmd.RunE = func(cmd *cobra.Command, args []string) error {
378378 name := args[0]
379379 // Check the configuration before asking anything.
380- c, err := env.client(cmd, true)
380+ c, err := env.client(cmd)
381381 if err != nil {
382382 return err
383383 }
@@ -86,7 +86,7 @@ func newRepoCmd(env *cliEnv) *cobra.Command {
86 repo := &cobra.Command{86 repo := &cobra.Command{
87 Use: "repo",87 Use: "repo",
88 Short: "Manage repositories on a remote server",88 Short: "Manage repositories on a remote server",
89- Long: "Manage repositories on the server at ERMITE_URL, authenticated with ERMITE_TOKEN.",89+ Long: "Manage repositories on the configured server (ERMITE_URL or the config file, see `ermite config`), authenticated with ERMITE_TOKEN or the token command of the config file.",
90 }90 }
91 repo.AddCommand(91 repo.AddCommand(
92 newRepoListCmd(env), newRepoShowCmd(env), newRepoCreateCmd(env),92 newRepoListCmd(env), newRepoShowCmd(env), newRepoCreateCmd(env),
@@ -146,7 +146,7 @@ func newRepoListCmd(env *cliEnv) *cobra.Command {
146 }146 }
147 asJSON := addJSONFlag(cmd)147 asJSON := addJSONFlag(cmd)
148 cmd.RunE = func(cmd *cobra.Command, _ []string) error {148 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
149- c, err := env.client(cmd, true)149+ c, err := env.client(cmd)
150 if err != nil {150 if err != nil {
151 return err151 return err
152 }152 }
@@ -205,7 +205,7 @@ func newRepoShowCmd(env *cliEnv) *cobra.Command {
205 }205 }
206 asJSON := addJSONFlag(cmd)206 asJSON := addJSONFlag(cmd)
207 cmd.RunE = func(cmd *cobra.Command, args []string) error {207 cmd.RunE = func(cmd *cobra.Command, args []string) error {
208- c, err := env.client(cmd, true)208+ c, err := env.client(cmd)
209 if err != nil {209 if err != nil {
210 return err210 return err
211 }211 }
@@ -239,7 +239,7 @@ func newRepoCreateCmd(env *cliEnv) *cobra.Command {
239 if err != nil {239 if err != nil {
240 return err240 return err
241 }241 }
242- c, err := env.client(cmd, true)242+ c, err := env.client(cmd)
243 if err != nil {243 if err != nil {
244 return err244 return err
245 }245 }
@@ -295,7 +295,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
295 if err != nil {295 if err != nil {
296 return err296 return err
297 }297 }
298- c, err := env.client(cmd, true)298+ c, err := env.client(cmd)
299 if err != nil {299 if err != nil {
300 return err300 return err
301 }301 }
@@ -377,7 +377,7 @@ func newRepoDeleteCmd(env *cliEnv) *cobra.Command {
377 cmd.RunE = func(cmd *cobra.Command, args []string) error {377 cmd.RunE = func(cmd *cobra.Command, args []string) error {
378 name := args[0]378 name := args[0]
379 // Check the configuration before asking anything.379 // Check the configuration before asking anything.
380- c, err := env.client(cmd, true)380+ c, err := env.client(cmd)
381 if err != nil {381 if err != nil {
382 return err382 return err
383 }383 }
M internal/app/cli_run.go +5 -5
@@ -186,7 +186,7 @@ func newRunListCmd(env *cliEnv) *cobra.Command {
186186 if limit < 1 {
187187 return fmt.Errorf("--limit must be at least 1")
188188 }
189- c, err := env.client(cmd, true)
189+ c, err := env.client(cmd)
190190 if err != nil {
191191 return err
192192 }
@@ -273,7 +273,7 @@ func newRunViewCmd(env *cliEnv) *cobra.Command {
273273 if err != nil {
274274 return err
275275 }
276- c, err := env.client(cmd, true)
276+ c, err := env.client(cmd)
277277 if err != nil {
278278 return err
279279 }
@@ -434,7 +434,7 @@ func newRunWatchCmd(env *cliEnv) *cobra.Command {
434434 if err != nil {
435435 return err
436436 }
437- c, err := env.client(cmd, true)
437+ c, err := env.client(cmd)
438438 if err != nil {
439439 return err
440440 }
@@ -494,7 +494,7 @@ func newRunCancelCmd(env *cliEnv) *cobra.Command {
494494 if err != nil {
495495 return err
496496 }
497- c, err := env.client(cmd, true)
497+ c, err := env.client(cmd)
498498 if err != nil {
499499 return err
500500 }
@@ -527,7 +527,7 @@ func newRunRerunCmd(env *cliEnv) *cobra.Command {
527527 if err != nil {
528528 return err
529529 }
530- c, err := env.client(cmd, true)
530+ c, err := env.client(cmd)
531531 if err != nil {
532532 return err
533533 }
@@ -186,7 +186,7 @@ func newRunListCmd(env *cliEnv) *cobra.Command {
186 if limit < 1 {186 if limit < 1 {
187 return fmt.Errorf("--limit must be at least 1")187 return fmt.Errorf("--limit must be at least 1")
188 }188 }
189- c, err := env.client(cmd, true)189+ c, err := env.client(cmd)
190 if err != nil {190 if err != nil {
191 return err191 return err
192 }192 }
@@ -273,7 +273,7 @@ func newRunViewCmd(env *cliEnv) *cobra.Command {
273 if err != nil {273 if err != nil {
274 return err274 return err
275 }275 }
276- c, err := env.client(cmd, true)276+ c, err := env.client(cmd)
277 if err != nil {277 if err != nil {
278 return err278 return err
279 }279 }
@@ -434,7 +434,7 @@ func newRunWatchCmd(env *cliEnv) *cobra.Command {
434 if err != nil {434 if err != nil {
435 return err435 return err
436 }436 }
437- c, err := env.client(cmd, true)437+ c, err := env.client(cmd)
438 if err != nil {438 if err != nil {
439 return err439 return err
440 }440 }
@@ -494,7 +494,7 @@ func newRunCancelCmd(env *cliEnv) *cobra.Command {
494 if err != nil {494 if err != nil {
495 return err495 return err
496 }496 }
497- c, err := env.client(cmd, true)497+ c, err := env.client(cmd)
498 if err != nil {498 if err != nil {
499 return err499 return err
500 }500 }
@@ -527,7 +527,7 @@ func newRunRerunCmd(env *cliEnv) *cobra.Command {
527 if err != nil {527 if err != nil {
528 return err528 return err
529 }529 }
530- c, err := env.client(cmd, true)530+ c, err := env.client(cmd)
531 if err != nil {531 if err != nil {
532 return err532 return err
533 }533 }
M internal/app/cli_runner.go +2 -2
@@ -55,7 +55,7 @@ func newRunnerListCmd(env *cliEnv) *cobra.Command {
5555 }
5656 asJSON := addJSONFlag(cmd)
5757 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
58- c, err := env.client(cmd, true)
58+ c, err := env.client(cmd)
5959 if err != nil {
6060 return err
6161 }
@@ -129,7 +129,7 @@ func newRunnerRemoveCmd(env *cliEnv) *cobra.Command {
129129 },
130130 }
131131 cmd.RunE = func(cmd *cobra.Command, args []string) error {
132- c, err := env.client(cmd, true)
132+ c, err := env.client(cmd)
133133 if err != nil {
134134 return err
135135 }
@@ -55,7 +55,7 @@ func newRunnerListCmd(env *cliEnv) *cobra.Command {
55 }55 }
56 asJSON := addJSONFlag(cmd)56 asJSON := addJSONFlag(cmd)
57 cmd.RunE = func(cmd *cobra.Command, _ []string) error {57 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
58- c, err := env.client(cmd, true)58+ c, err := env.client(cmd)
59 if err != nil {59 if err != nil {
60 return err60 return err
61 }61 }
@@ -129,7 +129,7 @@ func newRunnerRemoveCmd(env *cliEnv) *cobra.Command {
129 },129 },
130 }130 }
131 cmd.RunE = func(cmd *cobra.Command, args []string) error {131 cmd.RunE = func(cmd *cobra.Command, args []string) error {
132- c, err := env.client(cmd, true)132+ c, err := env.client(cmd)
133 if err != nil {133 if err != nil {
134 return err134 return err
135 }135 }
A internal/app/cli_shell_unix.go +80 -0
@@ -0,0 +1,80 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build !windows
4+
5+package app
6+
7+import (
8+ "context"
9+ "fmt"
10+ "io/fs"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "syscall"
15+)
16+
17+// shellCommand runs line with `sh -c`.
18+//
19+// When foreground is false (no terminal: a script or an agent), the command
20+// gets its own process group and a cancellation, a timeout or a signal kills
21+// the whole group, so a grandchild such as `sleep` or a secret manager helper
22+// cannot outlive ermite or keep the output pipe open.
23+//
24+// When foreground is true (standard input is a terminal) the command stays in
25+// the foreground process group of the terminal: a pinentry or a password
26+// prompt then works, and a background group would be stopped by SIGTTIN
27+// when it reads the terminal. Ctrl+C reaches the whole group. The limit: on a
28+// timeout or a SIGTERM only sh is killed, not what it started.
29+func shellCommand(ctx context.Context, line string, foreground bool) *exec.Cmd {
30+ c := exec.CommandContext(ctx, "sh", "-c", line)
31+ if !foreground {
32+ c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
33+ c.Cancel = func() error {
34+ // A negative pid is the process group, led by sh.
35+ return syscall.Kill(-c.Process.Pid, syscall.SIGKILL)
36+ }
37+ }
38+ return c
39+}
40+
41+// openConfig opens the config file without blocking on a FIFO swapped in for it.
42+func openConfig(path string) (*os.File, error) {
43+ return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
44+}
45+
46+// checkPrivate says whether token.cmd may be run from the config file at
47+// path, whose open handle reported info: whoever can change the file can make
48+// token.cmd any command. It returns nil when the file belongs to euid (the
49+// effective user id of the process) and cannot be written by group or others,
50+// and when its directory belongs to euid or root and cannot be written by
51+// group or others (a sticky directory excepted), as they could replace the
52+// file.
53+func checkPrivate(path string, info fs.FileInfo, euid int) error {
54+ if st, ok := info.Sys().(*syscall.Stat_t); ok && int(st.Uid) != euid {
55+ return fmt.Errorf("config file %s is owned by another user: token.cmd not run", path)
56+ }
57+ if info.Mode().Perm()&0o022 != 0 {
58+ return fmt.Errorf("config file %s is writable by others: token.cmd not run", path)
59+ }
60+ dir := filepath.Dir(path)
61+ if resolved, err := filepath.EvalSymlinks(path); err == nil {
62+ dir = filepath.Dir(resolved) // a symlinked dotfile: the directory of the target
63+ }
64+ return checkPrivateDir(dir, euid)
65+}
66+
67+// checkPrivateDir is the directory half of checkPrivate.
68+func checkPrivateDir(dir string, euid int) error {
69+ di, err := os.Stat(dir)
70+ if err != nil {
71+ return fmt.Errorf("config directory %s: %w: token.cmd not run", dir, err)
72+ }
73+ if st, ok := di.Sys().(*syscall.Stat_t); ok && int(st.Uid) != euid && st.Uid != 0 {
74+ return fmt.Errorf("config directory %s is owned by another user: token.cmd not run", dir)
75+ }
76+ if di.Mode().Perm()&0o022 != 0 && di.Mode()&fs.ModeSticky == 0 {
77+ return fmt.Errorf("config directory %s is writable by others: token.cmd not run", dir)
78+ }
79+ return nil
80+}
@@ -0,0 +1,80 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build !windows
4+
5+package app
6+
7+import (
8+ "context"
9+ "fmt"
10+ "io/fs"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "syscall"
15+)
16+
17+// shellCommand runs line with `sh -c`.
18+//
19+// When foreground is false (no terminal: a script or an agent), the command
20+// gets its own process group and a cancellation, a timeout or a signal kills
21+// the whole group, so a grandchild such as `sleep` or a secret manager helper
22+// cannot outlive ermite or keep the output pipe open.
23+//
24+// When foreground is true (standard input is a terminal) the command stays in
25+// the foreground process group of the terminal: a pinentry or a password
26+// prompt then works, and a background group would be stopped by SIGTTIN
27+// when it reads the terminal. Ctrl+C reaches the whole group. The limit: on a
28+// timeout or a SIGTERM only sh is killed, not what it started.
29+func shellCommand(ctx context.Context, line string, foreground bool) *exec.Cmd {
30+ c := exec.CommandContext(ctx, "sh", "-c", line)
31+ if !foreground {
32+ c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
33+ c.Cancel = func() error {
34+ // A negative pid is the process group, led by sh.
35+ return syscall.Kill(-c.Process.Pid, syscall.SIGKILL)
36+ }
37+ }
38+ return c
39+}
40+
41+// openConfig opens the config file without blocking on a FIFO swapped in for it.
42+func openConfig(path string) (*os.File, error) {
43+ return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
44+}
45+
46+// checkPrivate says whether token.cmd may be run from the config file at
47+// path, whose open handle reported info: whoever can change the file can make
48+// token.cmd any command. It returns nil when the file belongs to euid (the
49+// effective user id of the process) and cannot be written by group or others,
50+// and when its directory belongs to euid or root and cannot be written by
51+// group or others (a sticky directory excepted), as they could replace the
52+// file.
53+func checkPrivate(path string, info fs.FileInfo, euid int) error {
54+ if st, ok := info.Sys().(*syscall.Stat_t); ok && int(st.Uid) != euid {
55+ return fmt.Errorf("config file %s is owned by another user: token.cmd not run", path)
56+ }
57+ if info.Mode().Perm()&0o022 != 0 {
58+ return fmt.Errorf("config file %s is writable by others: token.cmd not run", path)
59+ }
60+ dir := filepath.Dir(path)
61+ if resolved, err := filepath.EvalSymlinks(path); err == nil {
62+ dir = filepath.Dir(resolved) // a symlinked dotfile: the directory of the target
63+ }
64+ return checkPrivateDir(dir, euid)
65+}
66+
67+// checkPrivateDir is the directory half of checkPrivate.
68+func checkPrivateDir(dir string, euid int) error {
69+ di, err := os.Stat(dir)
70+ if err != nil {
71+ return fmt.Errorf("config directory %s: %w: token.cmd not run", dir, err)
72+ }
73+ if st, ok := di.Sys().(*syscall.Stat_t); ok && int(st.Uid) != euid && st.Uid != 0 {
74+ return fmt.Errorf("config directory %s is owned by another user: token.cmd not run", dir)
75+ }
76+ if di.Mode().Perm()&0o022 != 0 && di.Mode()&fs.ModeSticky == 0 {
77+ return fmt.Errorf("config directory %s is writable by others: token.cmd not run", dir)
78+ }
79+ return nil
80+}
A internal/app/cli_shell_unix_test.go +160 -0
@@ -0,0 +1,160 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build !windows
4+
5+package app
6+
7+import (
8+ "context"
9+ "errors"
10+ "io/fs"
11+ "os"
12+ "path/filepath"
13+ "strconv"
14+ "strings"
15+ "syscall"
16+ "testing"
17+ "time"
18+
19+ "github.com/stretchr/testify/assert"
20+ "github.com/stretchr/testify/require"
21+)
22+
23+// processGone reports whether pid is gone (a zombie that nobody reaped counts).
24+func processGone(pid int) bool {
25+ if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) {
26+ return true
27+ }
28+ stat, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
29+ if err != nil {
30+ return false
31+ }
32+ _, after, _ := strings.Cut(string(stat), ") ")
33+ return strings.HasPrefix(after, "Z")
34+}
35+
36+// readPid waits for the pid file written by a grandchild.
37+func readPid(t *testing.T, path string) int {
38+ t.Helper()
39+ var pid int
40+ require.Eventually(t, func() bool {
41+ data, err := os.ReadFile(path)
42+ if err != nil {
43+ return false
44+ }
45+ pid, err = strconv.Atoi(strings.TrimSpace(string(data)))
46+ return err == nil && pid > 0
47+ }, 5*time.Second, 10*time.Millisecond)
48+ return pid
49+}
50+
51+func TestRunShellKillsTheWholeProcessTreeOnTimeoutAndCancel(t *testing.T) {
52+ requireNoTerminal(t)
53+ for name, tc := range map[string]struct {
54+ ctx func(t *testing.T) context.Context
55+ timeout time.Duration
56+ want string
57+ }{
58+ "timeout": {func(t *testing.T) context.Context { return t.Context() }, 700 * time.Millisecond, "timed out after 700ms"},
59+ "cancel": {func(t *testing.T) context.Context {
60+ ctx, cancel := context.WithCancel(t.Context())
61+ time.AfterFunc(700*time.Millisecond, cancel)
62+ return ctx
63+ }, time.Minute, "interrupted"},
64+ } {
65+ t.Run(name, func(t *testing.T) {
66+ pidFile := filepath.Join(t.TempDir(), "pid")
67+ // sh starts a grandchild, records its pid and waits for it
68+ line := "sleep 30 & echo $! > '" + pidFile + "'; wait"
69+ _, err := runShell(tc.ctx(t), line, tc.timeout)
70+ require.Error(t, err)
71+ assert.Equal(t, tc.want, err.Error())
72+
73+ pid := readPid(t, pidFile)
74+ require.Eventually(t, func() bool { return processGone(pid) }, 5*time.Second, 20*time.Millisecond,
75+ "the grandchild %d must not outlive the command", pid)
76+ })
77+ }
78+}
79+
80+func TestRunShellStopsOnSIGTERM(t *testing.T) {
81+ requireNoTerminal(t)
82+ pidFile := filepath.Join(t.TempDir(), "pid")
83+ line := "sleep 30 & echo $! > '" + pidFile + "'; wait"
84+ errc := make(chan error, 1)
85+ go func() {
86+ _, err := runShell(t.Context(), line, time.Minute)
87+ errc <- err
88+ }()
89+ pid := readPid(t, pidFile)
90+ // the signal is delivered to the test process itself: runShell catches it
91+ require.NoError(t, syscall.Kill(os.Getpid(), syscall.SIGTERM))
92+ select {
93+ case err := <-errc:
94+ require.Error(t, err)
95+ assert.Equal(t, "interrupted", err.Error())
96+ case <-time.After(10 * time.Second):
97+ t.Fatal("runShell did not stop on SIGTERM")
98+ }
99+ require.Eventually(t, func() bool { return processGone(pid) }, 5*time.Second, 20*time.Millisecond)
100+}
101+
102+// fakeInfo is a file whose owner and mode are chosen by the test.
103+type fakeInfo struct {
104+ fs.FileInfo
105+ mode fs.FileMode
106+ uid uint32
107+}
108+
109+func (f fakeInfo) Mode() fs.FileMode { return f.mode }
110+func (f fakeInfo) Sys() any { return &syscall.Stat_t{Uid: f.uid} }
111+
112+func TestCheckPrivate(t *testing.T) {
113+ dir := t.TempDir() // owned by the test user, mode 0700
114+ path := filepath.Join(dir, "config")
115+ require.NoError(t, os.WriteFile(path, nil, 0o600))
116+ real, err := os.Stat(path)
117+ require.NoError(t, err)
118+ me := os.Geteuid()
119+
120+ tests := []struct {
121+ name string
122+ mode fs.FileMode
123+ uid int // owner of the file
124+ euid int
125+ want string // error text, "" for accepted
126+ }{
127+ {"own private file", 0o600, me, me, ""},
128+ {"file of another user", 0o600, me + 1, me, "is owned by another user"},
129+ {"file owned by root, not me", 0o600, 0, me + 1, "is owned by another user"},
130+ {"group writable", 0o620, me, me, "is writable by others"},
131+ {"world writable", 0o602, me, me, "is writable by others"},
132+ {"group readable", 0o640, me, me, ""},
133+ // the directory is the real one: owned by me
134+ {"directory of another user", 0o600, me + 1, me + 1, "config directory"},
135+ }
136+ for _, tt := range tests {
137+ t.Run(tt.name, func(t *testing.T) {
138+ if tt.want == "config directory" && me == 0 {
139+ t.Skip("root owns every directory it creates")
140+ }
141+ err := checkPrivate(path, fakeInfo{FileInfo: real, mode: tt.mode, uid: uint32(tt.uid)}, tt.euid)
142+ if tt.want == "" {
143+ assert.NoError(t, err)
144+ return
145+ }
146+ require.Error(t, err)
147+ assert.Contains(t, err.Error(), tt.want)
148+ })
149+ }
150+
151+ t.Run("directory owned by root is fine", func(t *testing.T) {
152+ // /tmp and friends are root's; use a root-owned directory when there is one
153+ fi, err := os.Stat("/")
154+ require.NoError(t, err)
155+ if st, ok := fi.Sys().(*syscall.Stat_t); !ok || st.Uid != 0 || fi.Mode().Perm()&0o022 != 0 {
156+ t.Skip("/ is not a private root directory here")
157+ }
158+ assert.NoError(t, checkPrivateDir("/", me+1))
159+ })
160+}
@@ -0,0 +1,160 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build !windows
4+
5+package app
6+
7+import (
8+ "context"
9+ "errors"
10+ "io/fs"
11+ "os"
12+ "path/filepath"
13+ "strconv"
14+ "strings"
15+ "syscall"
16+ "testing"
17+ "time"
18+
19+ "github.com/stretchr/testify/assert"
20+ "github.com/stretchr/testify/require"
21+)
22+
23+// processGone reports whether pid is gone (a zombie that nobody reaped counts).
24+func processGone(pid int) bool {
25+ if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) {
26+ return true
27+ }
28+ stat, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
29+ if err != nil {
30+ return false
31+ }
32+ _, after, _ := strings.Cut(string(stat), ") ")
33+ return strings.HasPrefix(after, "Z")
34+}
35+
36+// readPid waits for the pid file written by a grandchild.
37+func readPid(t *testing.T, path string) int {
38+ t.Helper()
39+ var pid int
40+ require.Eventually(t, func() bool {
41+ data, err := os.ReadFile(path)
42+ if err != nil {
43+ return false
44+ }
45+ pid, err = strconv.Atoi(strings.TrimSpace(string(data)))
46+ return err == nil && pid > 0
47+ }, 5*time.Second, 10*time.Millisecond)
48+ return pid
49+}
50+
51+func TestRunShellKillsTheWholeProcessTreeOnTimeoutAndCancel(t *testing.T) {
52+ requireNoTerminal(t)
53+ for name, tc := range map[string]struct {
54+ ctx func(t *testing.T) context.Context
55+ timeout time.Duration
56+ want string
57+ }{
58+ "timeout": {func(t *testing.T) context.Context { return t.Context() }, 700 * time.Millisecond, "timed out after 700ms"},
59+ "cancel": {func(t *testing.T) context.Context {
60+ ctx, cancel := context.WithCancel(t.Context())
61+ time.AfterFunc(700*time.Millisecond, cancel)
62+ return ctx
63+ }, time.Minute, "interrupted"},
64+ } {
65+ t.Run(name, func(t *testing.T) {
66+ pidFile := filepath.Join(t.TempDir(), "pid")
67+ // sh starts a grandchild, records its pid and waits for it
68+ line := "sleep 30 & echo $! > '" + pidFile + "'; wait"
69+ _, err := runShell(tc.ctx(t), line, tc.timeout)
70+ require.Error(t, err)
71+ assert.Equal(t, tc.want, err.Error())
72+
73+ pid := readPid(t, pidFile)
74+ require.Eventually(t, func() bool { return processGone(pid) }, 5*time.Second, 20*time.Millisecond,
75+ "the grandchild %d must not outlive the command", pid)
76+ })
77+ }
78+}
79+
80+func TestRunShellStopsOnSIGTERM(t *testing.T) {
81+ requireNoTerminal(t)
82+ pidFile := filepath.Join(t.TempDir(), "pid")
83+ line := "sleep 30 & echo $! > '" + pidFile + "'; wait"
84+ errc := make(chan error, 1)
85+ go func() {
86+ _, err := runShell(t.Context(), line, time.Minute)
87+ errc <- err
88+ }()
89+ pid := readPid(t, pidFile)
90+ // the signal is delivered to the test process itself: runShell catches it
91+ require.NoError(t, syscall.Kill(os.Getpid(), syscall.SIGTERM))
92+ select {
93+ case err := <-errc:
94+ require.Error(t, err)
95+ assert.Equal(t, "interrupted", err.Error())
96+ case <-time.After(10 * time.Second):
97+ t.Fatal("runShell did not stop on SIGTERM")
98+ }
99+ require.Eventually(t, func() bool { return processGone(pid) }, 5*time.Second, 20*time.Millisecond)
100+}
101+
102+// fakeInfo is a file whose owner and mode are chosen by the test.
103+type fakeInfo struct {
104+ fs.FileInfo
105+ mode fs.FileMode
106+ uid uint32
107+}
108+
109+func (f fakeInfo) Mode() fs.FileMode { return f.mode }
110+func (f fakeInfo) Sys() any { return &syscall.Stat_t{Uid: f.uid} }
111+
112+func TestCheckPrivate(t *testing.T) {
113+ dir := t.TempDir() // owned by the test user, mode 0700
114+ path := filepath.Join(dir, "config")
115+ require.NoError(t, os.WriteFile(path, nil, 0o600))
116+ real, err := os.Stat(path)
117+ require.NoError(t, err)
118+ me := os.Geteuid()
119+
120+ tests := []struct {
121+ name string
122+ mode fs.FileMode
123+ uid int // owner of the file
124+ euid int
125+ want string // error text, "" for accepted
126+ }{
127+ {"own private file", 0o600, me, me, ""},
128+ {"file of another user", 0o600, me + 1, me, "is owned by another user"},
129+ {"file owned by root, not me", 0o600, 0, me + 1, "is owned by another user"},
130+ {"group writable", 0o620, me, me, "is writable by others"},
131+ {"world writable", 0o602, me, me, "is writable by others"},
132+ {"group readable", 0o640, me, me, ""},
133+ // the directory is the real one: owned by me
134+ {"directory of another user", 0o600, me + 1, me + 1, "config directory"},
135+ }
136+ for _, tt := range tests {
137+ t.Run(tt.name, func(t *testing.T) {
138+ if tt.want == "config directory" && me == 0 {
139+ t.Skip("root owns every directory it creates")
140+ }
141+ err := checkPrivate(path, fakeInfo{FileInfo: real, mode: tt.mode, uid: uint32(tt.uid)}, tt.euid)
142+ if tt.want == "" {
143+ assert.NoError(t, err)
144+ return
145+ }
146+ require.Error(t, err)
147+ assert.Contains(t, err.Error(), tt.want)
148+ })
149+ }
150+
151+ t.Run("directory owned by root is fine", func(t *testing.T) {
152+ // /tmp and friends are root's; use a root-owned directory when there is one
153+ fi, err := os.Stat("/")
154+ require.NoError(t, err)
155+ if st, ok := fi.Sys().(*syscall.Stat_t); !ok || st.Uid != 0 || fi.Mode().Perm()&0o022 != 0 {
156+ t.Skip("/ is not a private root directory here")
157+ }
158+ assert.NoError(t, checkPrivateDir("/", me+1))
159+ })
160+}
A internal/app/cli_shell_windows.go +34 -0
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build windows
4+
5+package app
6+
7+import (
8+ "context"
9+ "io/fs"
10+ "os"
11+ "os/exec"
12+ "syscall"
13+)
14+
15+// shellCommand runs line with `cmd /C`. The command line is passed as is:
16+// Go's argument quoting would mangle quotes cmd.exe means to read itself.
17+//
18+// The command is killed with exec.CommandContext on a timeout or a signal:
19+// only cmd.exe itself, not what it started (there is no process group kill
20+// here); foreground is ignored.
21+func shellCommand(ctx context.Context, line string, _ bool) *exec.Cmd {
22+ c := exec.CommandContext(ctx, "cmd")
23+ c.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd /C " + line}
24+ return c
25+}
26+
27+// openConfig opens the config file.
28+func openConfig(path string) (*os.File, error) { return os.Open(path) }
29+
30+// checkPrivate is a no-op on Windows: it has no Unix mode bits or owner ids to
31+// check, so token.cmd is run from a config file whoever can write it. The
32+// per-user config directory (%AppData%) is private to the user by default;
33+// keep ERMITE_CONFIG out of shared folders.
34+func checkPrivate(string, fs.FileInfo, int) error { return nil }
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build windows
4+
5+package app
6+
7+import (
8+ "context"
9+ "io/fs"
10+ "os"
11+ "os/exec"
12+ "syscall"
13+)
14+
15+// shellCommand runs line with `cmd /C`. The command line is passed as is:
16+// Go's argument quoting would mangle quotes cmd.exe means to read itself.
17+//
18+// The command is killed with exec.CommandContext on a timeout or a signal:
19+// only cmd.exe itself, not what it started (there is no process group kill
20+// here); foreground is ignored.
21+func shellCommand(ctx context.Context, line string, _ bool) *exec.Cmd {
22+ c := exec.CommandContext(ctx, "cmd")
23+ c.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd /C " + line}
24+ return c
25+}
26+
27+// openConfig opens the config file.
28+func openConfig(path string) (*os.File, error) { return os.Open(path) }
29+
30+// checkPrivate is a no-op on Windows: it has no Unix mode bits or owner ids to
31+// check, so token.cmd is run from a config file whoever can write it. The
32+// per-user config directory (%AppData%) is private to the user by default;
33+// keep ERMITE_CONFIG out of shared folders.
34+func checkPrivate(string, fs.FileInfo, int) error { return nil }
M internal/app/cli_signing.go +1 -1
@@ -55,7 +55,7 @@ func newSigningKeyShowCmd(env *cliEnv) *cobra.Command {
5555 cmd.MarkFlagsMutuallyExclusive("json", "public-key")
5656 cmd.MarkFlagsMutuallyExclusive("json", "allowed-signers")
5757 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
58- c, err := env.client(cmd, true)
58+ c, err := env.client(cmd)
5959 if err != nil {
6060 return err
6161 }
@@ -55,7 +55,7 @@ func newSigningKeyShowCmd(env *cliEnv) *cobra.Command {
55 cmd.MarkFlagsMutuallyExclusive("json", "public-key")55 cmd.MarkFlagsMutuallyExclusive("json", "public-key")
56 cmd.MarkFlagsMutuallyExclusive("json", "allowed-signers")56 cmd.MarkFlagsMutuallyExclusive("json", "allowed-signers")
57 cmd.RunE = func(cmd *cobra.Command, _ []string) error {57 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
58- c, err := env.client(cmd, true)58+ c, err := env.client(cmd)
59 if err != nil {59 if err != nil {
60 return err60 return err
61 }61 }
M internal/app/cli_token.go +3 -3
@@ -108,7 +108,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
108108 if err != nil {
109109 return err
110110 }
111- c, err := env.client(cmd, true)
111+ c, err := env.client(cmd)
112112 if err != nil {
113113 return err
114114 }
@@ -186,7 +186,7 @@ func newTokenListCmd(env *cliEnv) *cobra.Command {
186186 }
187187 asJSON := addJSONFlag(cmd)
188188 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
189- c, err := env.client(cmd, true)
189+ c, err := env.client(cmd)
190190 if err != nil {
191191 return err
192192 }
@@ -240,7 +240,7 @@ func newTokenRevokeCmd(env *cliEnv) *cobra.Command {
240240 return ids, cobra.ShellCompDirectiveNoFileComp
241241 },
242242 RunE: func(cmd *cobra.Command, args []string) error {
243- c, err := env.client(cmd, true)
243+ c, err := env.client(cmd)
244244 if err != nil {
245245 return err
246246 }
@@ -108,7 +108,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
108 if err != nil {108 if err != nil {
109 return err109 return err
110 }110 }
111- c, err := env.client(cmd, true)111+ c, err := env.client(cmd)
112 if err != nil {112 if err != nil {
113 return err113 return err
114 }114 }
@@ -186,7 +186,7 @@ func newTokenListCmd(env *cliEnv) *cobra.Command {
186 }186 }
187 asJSON := addJSONFlag(cmd)187 asJSON := addJSONFlag(cmd)
188 cmd.RunE = func(cmd *cobra.Command, _ []string) error {188 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
189- c, err := env.client(cmd, true)189+ c, err := env.client(cmd)
190 if err != nil {190 if err != nil {
191 return err191 return err
192 }192 }
@@ -240,7 +240,7 @@ func newTokenRevokeCmd(env *cliEnv) *cobra.Command {
240 return ids, cobra.ShellCompDirectiveNoFileComp240 return ids, cobra.ShellCompDirectiveNoFileComp
241 },241 },
242 RunE: func(cmd *cobra.Command, args []string) error {242 RunE: func(cmd *cobra.Command, args []string) error {
243- c, err := env.client(cmd, true)243+ c, err := env.client(cmd)
244 if err != nil {244 if err != nil {
245 return err245 return err
246 }246 }
M internal/app/cli_version.go +16 -7
@@ -33,11 +33,13 @@ type jsonServerVersion struct {
3333 func newVersionCmd(env *cliEnv) *cobra.Command {
3434 cmd := &cobra.Command{
3535 Use: "version",
36- Short: "Print the client version and, when ERMITE_URL is set, the server's",
37- Long: "Print the version of this client and, when ERMITE_URL is set, the version of the server and\n" +
38- "the optional features of the API it implements. A warning on standard error says which of the\n" +
39- "features this client uses the server lacks. Without ERMITE_URL only the client is shown.\n\n" +
40- "JSON fields (--json): client.version; server, null without ERMITE_URL, with url, version,\n" +
36+ Short: "Print the client version and, when a server URL is configured, the server's",
37+ Long: "Print the version of this client and, when a server URL is configured (ERMITE_URL or the url\n" +
38+ "of the config file, see `ermite config`), the version of the server and the optional features\n" +
39+ "of the API it implements. A warning on standard error says which of the features this client\n" +
40+ "uses the server lacks. Without a URL only the client is shown. The token command of the config\n" +
41+ "file is not run.\n\n" +
42+ "JSON fields (--json): client.version; server, null without a URL, with url, version,\n" +
4143 "features (array of identifiers) and missing_features (the ones this client uses that the\n" +
4244 "server lacks).",
4345 Args: cobra.NoArgs,
@@ -46,7 +48,13 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
4648 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
4749 out := cmd.OutOrStdout()
4850 clientLine := "ermite " + env.version
49- url := strings.TrimSpace(env.getenv(client.EnvURL))
51+ url, err := env.resolveURL(cmd, true)
52+ if err != nil {
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 = ""
57+ }
5058 if url == "" {
5159 if *asJSON {
5260 return writeJSON(out, jsonVersion{Client: jsonClientVersion{Version: env.version}})
@@ -55,7 +63,8 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
5563 return err
5664 }
5765
58- c, err := env.client(cmd, true)
66+ // GetVersion is anonymous: token.cmd is not run for it.
67+ c, err := env.clientNoTokenCmd(cmd, true)
5968 if err != nil {
6069 return err
6170 }
@@ -33,11 +33,13 @@ type jsonServerVersion struct {
33 func newVersionCmd(env *cliEnv) *cobra.Command {33 func newVersionCmd(env *cliEnv) *cobra.Command {
34 cmd := &cobra.Command{34 cmd := &cobra.Command{
35 Use: "version",35 Use: "version",
36- Short: "Print the client version and, when ERMITE_URL is set, the server's",36+ Short: "Print the client version and, when a server URL is configured, the server's",
37- Long: "Print the version of this client and, when ERMITE_URL is set, the version of the server and\n" +37+ Long: "Print the version of this client and, when a server URL is configured (ERMITE_URL or the url\n" +
38- "the optional features of the API it implements. A warning on standard error says which of the\n" +38+ "of the config file, see `ermite config`), the version of the server and the optional features\n" +
39- "features this client uses the server lacks. Without ERMITE_URL only the client is shown.\n\n" +39+ "of the API it implements. A warning on standard error says which of the features this client\n" +
40- "JSON fields (--json): client.version; server, null without ERMITE_URL, with url, version,\n" +40+ "uses the server lacks. Without a URL only the client is shown. The token command of the config\n" +
41+ "file is not run.\n\n" +
42+ "JSON fields (--json): client.version; server, null without a URL, with url, version,\n" +
41 "features (array of identifiers) and missing_features (the ones this client uses that the\n" +43 "features (array of identifiers) and missing_features (the ones this client uses that the\n" +
42 "server lacks).",44 "server lacks).",
43 Args: cobra.NoArgs,45 Args: cobra.NoArgs,
@@ -46,7 +48,13 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
46 cmd.RunE = func(cmd *cobra.Command, _ []string) error {48 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
47 out := cmd.OutOrStdout()49 out := cmd.OutOrStdout()
48 clientLine := "ermite " + env.version50 clientLine := "ermite " + env.version
49- url := strings.TrimSpace(env.getenv(client.EnvURL))51+ url, err := env.resolveURL(cmd, true)
52+ if err != nil {
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 = ""
57+ }
50 if url == "" {58 if url == "" {
51 if *asJSON {59 if *asJSON {
52 return writeJSON(out, jsonVersion{Client: jsonClientVersion{Version: env.version}})60 return writeJSON(out, jsonVersion{Client: jsonClientVersion{Version: env.version}})
@@ -55,7 +63,8 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
55 return err63 return err
56 }64 }
57 65
58- c, err := env.client(cmd, true)66+ // GetVersion is anonymous: token.cmd is not run for it.
67+ c, err := env.clientNoTokenCmd(cmd, true)
59 if err != nil {68 if err != nil {
60 return err69 return err
61 }70 }
M internal/app/cli_watch.go +1 -1
@@ -163,7 +163,7 @@ func newWatchCmd(env *cliEnv) *cobra.Command {
163163 if len(args) == 1 {
164164 topics = []string{"repo:" + args[0]}
165165 }
166- c, err := env.client(cmd, true)
166+ c, err := env.client(cmd)
167167 if err != nil {
168168 return err
169169 }
@@ -163,7 +163,7 @@ func newWatchCmd(env *cliEnv) *cobra.Command {
163 if len(args) == 1 {163 if len(args) == 1 {
164 topics = []string{"repo:" + args[0]}164 topics = []string{"repo:" + args[0]}
165 }165 }
166- c, err := env.client(cmd, true)166+ c, err := env.client(cmd)
167 if err != nil {167 if err != nil {
168 return err168 return err
169 }169 }
M internal/app/cli_web.go +2 -2
@@ -43,7 +43,7 @@ func newWebLoginCmd(env *cliEnv) *cobra.Command {
4343 }
4444 asJSON := addJSONFlag(cmd)
4545 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
46- c, err := env.client(cmd, true)
46+ c, err := env.client(cmd)
4747 if err != nil {
4848 return err
4949 }
@@ -75,7 +75,7 @@ func newWebLogoutCmd(env *cliEnv) *cobra.Command {
7575 if !all {
7676 return errors.New("--all is required: this signs every browser out (one browser signs out from its page)")
7777 }
78- c, err := env.client(cmd, true)
78+ c, err := env.client(cmd)
7979 if err != nil {
8080 return err
8181 }
@@ -43,7 +43,7 @@ func newWebLoginCmd(env *cliEnv) *cobra.Command {
43 }43 }
44 asJSON := addJSONFlag(cmd)44 asJSON := addJSONFlag(cmd)
45 cmd.RunE = func(cmd *cobra.Command, _ []string) error {45 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
46- c, err := env.client(cmd, true)46+ c, err := env.client(cmd)
47 if err != nil {47 if err != nil {
48 return err48 return err
49 }49 }
@@ -75,7 +75,7 @@ func newWebLogoutCmd(env *cliEnv) *cobra.Command {
75 if !all {75 if !all {
76 return errors.New("--all is required: this signs every browser out (one browser signs out from its page)")76 return errors.New("--all is required: this signs every browser out (one browser signs out from its page)")
77 }77 }
78- c, err := env.client(cmd, true)78+ c, err := env.client(cmd)
79 if err != nil {79 if err != nil {
80 return err80 return err
81 }81 }
M internal/app/root.go +9 -3
@@ -18,15 +18,21 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
1818 Short: "A single-user Git forge",
1919 Long: "ermite is a single-user Git forge: `ermite serve` runs the server, the other commands\n" +
2020 "drive a running server over its API.\n\n" +
21- "Remote commands read ERMITE_URL (the server address, http:// or https://) and\n" +
22- "ERMITE_TOKEN (a bearer token, see `ermite token create`) from the environment.",
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" +
23+ " ERMITE_URL / ERMITE_TOKEN the environment\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" +
26+ "token stays in your secret manager (pass, gopass, op, bw, secret-tool…), never in the file.\n" +
27+ "See `ermite config --help`.",
2328 SilenceUsage: true,
2429 SilenceErrors: true, // cmd/ermite prints the error once
2530 }
2631 root.AddCommand(
2732 newServeCmd(), newHookCmd(),
2833 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
29- newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newRunCmd(env), newRunnerCmd(env),
34+ newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
35+ newRunCmd(env), newRunnerCmd(env),
3036 )
3137 return root
3238 }
@@ -18,15 +18,21 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
18 Short: "A single-user Git forge",18 Short: "A single-user Git forge",
19 Long: "ermite is a single-user Git forge: `ermite serve` runs the server, the other commands\n" +19 Long: "ermite is a single-user Git forge: `ermite serve` runs the server, the other commands\n" +
20 "drive a running server over its API.\n\n" +20 "drive a running server over its API.\n\n" +
21- "Remote commands read ERMITE_URL (the server address, http:// or https://) and\n" +21+ "Remote commands need the server address (http:// or https://) and, except for public reads,\n" +
22- "ERMITE_TOKEN (a bearer token, see `ermite token create`) from the environment.",22+ "a bearer token (see `ermite token create`). Each comes from, in order:\n\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 `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" +
26+ "token stays in your secret manager (pass, gopass, op, bw, secret-tool…), never in the file.\n" +
27+ "See `ermite config --help`.",
23 SilenceUsage: true,28 SilenceUsage: true,
24 SilenceErrors: true, // cmd/ermite prints the error once29 SilenceErrors: true, // cmd/ermite prints the error once
25 }30 }
26 root.AddCommand(31 root.AddCommand(
27 newServeCmd(), newHookCmd(),32 newServeCmd(), newHookCmd(),
28 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),33 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
29- newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newRunCmd(env), newRunnerCmd(env),34+ newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
35+ newRunCmd(env), newRunnerCmd(env),
30 )36 )
31 return root37 return root
32 }38 }
M skills/ermite/CLI.md +3 -2
@@ -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). 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
@@ -77,7 +77,8 @@ Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `
7777 | Command | Notes |
7878 |---|---|
7979 | `serve` | configured by `ERMITE_*` environment variables only (see the project README) |
80-| `version [--json]` | client version; with `ERMITE_URL`, the server version and features |
80+| `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features |
81+| `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it |
8182 | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` |
8283 | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope and no repository list; the link is a secret |
8384 | `web logout --all` | signs every browser out |
@@ -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). 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
@@ -77,7 +77,8 @@ Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `
77 | Command | Notes |77 | Command | Notes |
78 |---|---|78 |---|---|
79 | `serve` | configured by `ERMITE_*` environment variables only (see the project README) |79 | `serve` | configured by `ERMITE_*` environment variables only (see the project README) |
80-| `version [--json]` | client version; with `ERMITE_URL`, the server version and features |80+| `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features |
81+| `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it |
81 | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` |82 | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` |
82 | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope and no repository list; the link is a secret |83 | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope and no repository list; the link is a secret |
83 | `web logout --all` | signs every browser out |84 | `web logout --all` | signs every browser out |
M skills/ermite/SKILL.md +2 -0
@@ -16,6 +16,8 @@ ermite version # client and server versions, warns about
1616 ermite repo list
1717 ```
1818
19+Instead of exporting them, the owner can keep `url` and a `token.cmd` (a command that prints the token, such as `pass show ermite/token`) in `~/.config/ermite/config`; as an agent, set `ERMITE_TOKEN` or point `ERMITE_CONFIG` at your own file.
20+
1921 Never print a token, commit it, log it or paste it into a chat. Prefer a Git credential helper to a token in a URL. Use `https://`: a token sent over plain `http://` to a remote host triggers a warning.
2022
2123 ## Git
@@ -16,6 +16,8 @@ ermite version # client and server versions, warns about
16 ermite repo list16 ermite repo list
17 ```17 ```
18 18
19+Instead of exporting them, the owner can keep `url` and a `token.cmd` (a command that prints the token, such as `pass show ermite/token`) in `~/.config/ermite/config`; as an agent, set `ERMITE_TOKEN` or point `ERMITE_CONFIG` at your own file.
20+
19 Never print a token, commit it, log it or paste it into a chat. Prefer a Git credential helper to a token in a URL. Use `https://`: a token sent over plain `http://` to a remote host triggers a warning.21 Never print a token, commit it, log it or paste it into a chat. Prefer a Git credential helper to a token in a URL. Use `https://`: a token sent over plain `http://` to a remote host triggers a warning.
20 22
21 ## Git23 ## Git

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close