detached c0892a5

feat(cli): read the server URL and a token command from a config file

ERMITE_URL and ERMITE_TOKEN still win. url and token.cmd come from ~/.config/ermite/config (ERMITE_CONFIG selects another). token.cmd runs through the system shell only when a command needs the token, at most once per process, never for version or config, and not when the file is writable by others. Its output is never printed. client.ConfigFromEnv is replaced by client.NewConfig.
commit
c0892a5b19b1fbad0f4aafd3d6699f69705100ad
author
brice · · 2026-10-12 14:58 UTC
parent
fc4d564
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +648 -34
M go.mod +1 -0
@@ -18,6 +18,7 @@ require (
1818 connectrpc.com/connect v1.21.0
1919 github.com/a-h/templ v0.3.1020
2020 github.com/alecthomas/chroma/v2 v2.27.0
21+ github.com/bnema/kvconf v0.1.2
2122 github.com/bnema/zerowrap v1.4.1
2223 github.com/golang-jwt/jwt/v5 v5.3.1
2324 github.com/spf13/cobra v1.10.2
@@ -18,6 +18,7 @@ require (
18 connectrpc.com/connect v1.21.018 connectrpc.com/connect v1.21.0
19 github.com/a-h/templ v0.3.102019 github.com/a-h/templ v0.3.1020
20 github.com/alecthomas/chroma/v2 v2.27.020 github.com/alecthomas/chroma/v2 v2.27.0
21+ github.com/bnema/kvconf v0.1.2
21 github.com/bnema/zerowrap v1.4.122 github.com/bnema/zerowrap v1.4.1
22 github.com/golang-jwt/jwt/v5 v5.3.123 github.com/golang-jwt/jwt/v5 v5.3.1
23 github.com/spf13/cobra v1.10.224 github.com/spf13/cobra v1.10.2
M go.sum +2 -0
@@ -106,6 +106,8 @@ github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6
106106 github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
107107 github.com/bnema/hexcheck v0.3.1 h1:Apv7w0KmsfbOBOq/r1jPlENX3Z7ukAEPlAxvWMTTd3I=
108108 github.com/bnema/hexcheck v0.3.1/go.mod h1:8KuFStFhn1qNKDBrUvxOB5VzSP/uuePrURqPhq2KVAk=
109+github.com/bnema/kvconf v0.1.2 h1:1hDKL9jkQSBdR9dSKbwTONhUUBfJgAdh5vu+yFHOb70=
110+github.com/bnema/kvconf v0.1.2/go.mod h1:+P2X9I/sLx+Lfir45s9a7WA0GnC22WgKbZWE9LK6lcU=
109111 github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU=
110112 github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg=
111113 github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
@@ -106,6 +106,8 @@ github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6
106 github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=106 github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
107 github.com/bnema/hexcheck v0.3.1 h1:Apv7w0KmsfbOBOq/r1jPlENX3Z7ukAEPlAxvWMTTd3I=107 github.com/bnema/hexcheck v0.3.1 h1:Apv7w0KmsfbOBOq/r1jPlENX3Z7ukAEPlAxvWMTTd3I=
108 github.com/bnema/hexcheck v0.3.1/go.mod h1:8KuFStFhn1qNKDBrUvxOB5VzSP/uuePrURqPhq2KVAk=108 github.com/bnema/hexcheck v0.3.1/go.mod h1:8KuFStFhn1qNKDBrUvxOB5VzSP/uuePrURqPhq2KVAk=
109+github.com/bnema/kvconf v0.1.2 h1:1hDKL9jkQSBdR9dSKbwTONhUUBfJgAdh5vu+yFHOb70=
110+github.com/bnema/kvconf v0.1.2/go.mod h1:+P2X9I/sLx+Lfir45s9a7WA0GnC22WgKbZWE9LK6lcU=
109 github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU=111 github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU=
110 github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg=112 github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg=
111 github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=113 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 (%s, or `ermite config set url <url>`): %w", EnvURL, 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 (%s, or `ermite config set url <url>`): %w", EnvURL, 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 +6 -10
@@ -11,20 +11,14 @@ 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 TestNewConfigRejects(t *testing.T) {
2822 tests := []struct {
2923 name, url, want string
3024 }{
@@ -39,9 +33,11 @@ func TestConfigFromEnvRejects(t *testing.T) {
3933 }
4034 for _, tt := range tests {
4135 t.Run(tt.name, func(t *testing.T) {
42- _, err := client.ConfigFromEnv(env(map[string]string{client.EnvURL: tt.url}))
36+ _, err := client.NewConfig(tt.url, "")
4337 require.Error(t, err)
4438 assert.Contains(t, err.Error(), tt.want)
39+ assert.Contains(t, err.Error(), "ermite config set url", "the message tells how to fix it")
40+ assert.Contains(t, err.Error(), "ERMITE_URL")
4541 assert.NotContains(t, err.Error(), "s3cret", "credentials must never be echoed")
4642 })
4743 }
@@ -11,20 +11,14 @@ 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 TestNewConfigRejects(t *testing.T) {
28 tests := []struct {22 tests := []struct {
29 name, url, want string23 name, url, want string
30 }{24 }{
@@ -39,9 +33,11 @@ func TestConfigFromEnvRejects(t *testing.T) {
39 }33 }
40 for _, tt := range tests {34 for _, tt := range tests {
41 t.Run(tt.name, func(t *testing.T) {35 t.Run(tt.name, func(t *testing.T) {
42- _, err := client.ConfigFromEnv(env(map[string]string{client.EnvURL: tt.url}))36+ _, err := client.NewConfig(tt.url, "")
43 require.Error(t, err)37 require.Error(t, err)
44 assert.Contains(t, err.Error(), tt.want)38 assert.Contains(t, err.Error(), tt.want)
39+ assert.Contains(t, err.Error(), "ermite config set url", "the message tells how to fix it")
40+ assert.Contains(t, err.Error(), "ERMITE_URL")
45 assert.NotContains(t, err.Error(), "s3cret", "credentials must never be echoed")41 assert.NotContains(t, err.Error(), "s3cret", "credentials must never be echoed")
46 })42 })
47 }43 }
M internal/app/cli.go +55 -4
@@ -9,6 +9,7 @@ import (
99 "io"
1010 "os"
1111 "strings"
12+ "sync"
1213 "text/tabwriter"
1314 "time"
1415
@@ -30,6 +31,23 @@ 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; nil means running it with the system shell. 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+ fileErr error
48+ tokenOnce sync.Once
49+ token string
50+ tokenErr error
3351 }
3452
3553 func defaultCLIEnv() *cliEnv {
@@ -54,9 +72,28 @@ func (e *cliEnv) ctx(cmd *cobra.Command) context.Context {
5472 return ctx
5573 }
5674
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)
75+// config builds the client configuration: ERMITE_URL and ERMITE_TOKEN win,
76+// then the config file (url, and the output of token.cmd for the token).
77+func (e *cliEnv) config(cmd *cobra.Command, warn bool) (client.Config, error) {
78+ return e.buildConfig(cmd, warn, true)
79+}
80+
81+// buildConfig is config; runTokenCmd false leaves token.cmd alone (ERMITE_TOKEN
82+// is still used), for the calls that need no authentication.
83+func (e *cliEnv) buildConfig(cmd *cobra.Command, warn, runTokenCmd bool) (client.Config, error) {
84+ rawURL, err := e.resolveURL(cmd, warn)
85+ if err != nil {
86+ return client.Config{}, err
87+ }
88+ // A missing or invalid URL is reported before token.cmd gets to prompt.
89+ if _, err := client.NewConfig(rawURL, ""); err != nil {
90+ return client.Config{}, err
91+ }
92+ token, err := e.resolveToken(cmd, warn, runTokenCmd)
93+ if err != nil {
94+ return client.Config{}, err
95+ }
96+ cfg, err := client.NewConfig(rawURL, token)
6097 if err != nil {
6198 return cfg, err
6299 }
@@ -67,10 +104,24 @@ func (e *cliEnv) config() (client.Config, error) {
67104 // client builds the API client. Commands warn when the token would travel in
68105 // the clear; completion stays silent.
69106 func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) {
70- cfg, err := e.config()
107+ cfg, err := e.config(cmd, warn)
108+ if err != nil {
109+ return nil, err
110+ }
111+ return newClient(cmd, cfg, warn)
112+}
113+
114+// clientNoTokenCmd is client without running token.cmd, for GetVersion, which
115+// is anonymous.
116+func (e *cliEnv) clientNoTokenCmd(cmd *cobra.Command, warn bool) (*client.Client, error) {
117+ cfg, err := e.buildConfig(cmd, warn, false)
71118 if err != nil {
72119 return nil, err
73120 }
121+ return newClient(cmd, cfg, warn)
122+}
123+
124+func newClient(cmd *cobra.Command, cfg client.Config, warn bool) (*client.Client, error) {
74125 if w := cfg.InsecureTokenWarning(); w != "" && warn {
75126 warnf(cmd, "%s", w)
76127 }
@@ -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,6 +31,23 @@ 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; nil means running it with the system shell. 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+ fileErr error
48+ tokenOnce sync.Once
49+ token string
50+ tokenErr error
33 }51 }
34 52
35 func defaultCLIEnv() *cliEnv {53 func defaultCLIEnv() *cliEnv {
@@ -54,9 +72,28 @@ func (e *cliEnv) ctx(cmd *cobra.Command) context.Context {
54 return ctx72 return ctx
55 }73 }
56 74
57-// config reads and validates ERMITE_URL and ERMITE_TOKEN.75+// config builds the client configuration: ERMITE_URL and ERMITE_TOKEN win,
58-func (e *cliEnv) config() (client.Config, error) {76+// then the config file (url, and the output of token.cmd for the token).
59- cfg, err := client.ConfigFromEnv(e.getenv)77+func (e *cliEnv) config(cmd *cobra.Command, warn bool) (client.Config, error) {
78+ return e.buildConfig(cmd, warn, true)
79+}
80+
81+// buildConfig is config; runTokenCmd false leaves token.cmd alone (ERMITE_TOKEN
82+// is still used), for the calls that need no authentication.
83+func (e *cliEnv) buildConfig(cmd *cobra.Command, warn, runTokenCmd bool) (client.Config, error) {
84+ rawURL, err := e.resolveURL(cmd, warn)
85+ if err != nil {
86+ return client.Config{}, err
87+ }
88+ // A missing or invalid URL is reported before token.cmd gets to prompt.
89+ if _, err := client.NewConfig(rawURL, ""); err != nil {
90+ return client.Config{}, err
91+ }
92+ token, err := e.resolveToken(cmd, warn, runTokenCmd)
93+ if err != nil {
94+ return client.Config{}, err
95+ }
96+ cfg, err := client.NewConfig(rawURL, token)
60 if err != nil {97 if err != nil {
61 return cfg, err98 return cfg, err
62 }99 }
@@ -67,10 +104,24 @@ func (e *cliEnv) config() (client.Config, error) {
67 // client builds the API client. Commands warn when the token would travel in104 // client builds the API client. Commands warn when the token would travel in
68 // the clear; completion stays silent.105 // the clear; completion stays silent.
69 func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) {106 func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) {
70- cfg, err := e.config()107+ cfg, err := e.config(cmd, warn)
108+ if err != nil {
109+ return nil, err
110+ }
111+ return newClient(cmd, cfg, warn)
112+}
113+
114+// clientNoTokenCmd is client without running token.cmd, for GetVersion, which
115+// is anonymous.
116+func (e *cliEnv) clientNoTokenCmd(cmd *cobra.Command, warn bool) (*client.Client, error) {
117+ cfg, err := e.buildConfig(cmd, warn, false)
71 if err != nil {118 if err != nil {
72 return nil, err119 return nil, err
73 }120 }
121+ return newClient(cmd, cfg, warn)
122+}
123+
124+func newClient(cmd *cobra.Command, cfg client.Config, warn bool) (*client.Client, error) {
74 if w := cfg.InsecureTokenWarning(); w != "" && warn {125 if w := cfg.InsecureTokenWarning(); w != "" && warn {
75 warnf(cmd, "%s", w)126 warnf(cmd, "%s", w)
76 }127 }
A internal/app/cli_configfile.go +208 -0
@@ -0,0 +1,208 @@
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+ "os"
12+ "os/exec"
13+ "runtime"
14+ "strings"
15+ "time"
16+
17+ "github.com/bnema/kvconf"
18+ "github.com/spf13/cobra"
19+
20+ "bnema.dev/ermite/internal/adapters/client"
21+)
22+
23+const (
24+ // envConfig selects the CLI config file instead of ~/.config/ermite/config.
25+ envConfig = "ERMITE_CONFIG"
26+ // Keys of the CLI config file.
27+ keyURL = "url"
28+ keyTokenCmd = "token.cmd"
29+
30+ // tokenCmdTimeout bounds one run of token.cmd, prompts included.
31+ tokenCmdTimeout = 60 * time.Second
32+ // tokenCmdMaxOutput bounds what is read from its standard output.
33+ tokenCmdMaxOutput = 4 << 10
34+)
35+
36+// cliFile is the CLI config file. It holds no secret: the token is fetched
37+// from a secret manager by the token.cmd command line.
38+type cliFile struct {
39+ URL string `kv:"url"`
40+ TokenCmd string `kv:"token.cmd"`
41+}
42+
43+// path is the config file: $ERMITE_CONFIG, else <config dir>/ermite/config.
44+func (e *cliEnv) path() (string, error) {
45+ if e.configPath != nil {
46+ return e.configPath()
47+ }
48+ if p := strings.TrimSpace(e.getenv(envConfig)); p != "" {
49+ return p, nil
50+ }
51+ p, err := kvconf.DefaultPath("ermite", "config")
52+ if err != nil {
53+ return "", fmt.Errorf("no home directory to find the config file: set %s", envConfig)
54+ }
55+ return p, nil
56+}
57+
58+// readFile reads the config file, once per process. A missing file, or no
59+// place to look for it, gives an empty one. Problems in the file are warnings
60+// on stderr, printed once, and only when warn is set.
61+func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, error) {
62+ e.fileOnce.Do(func() {
63+ p, err := e.path()
64+ if err != nil {
65+ // Reading is best effort when the location is unknown; the
66+ // `ermite config` commands report this error.
67+ e.file = &cliFile{}
68+ return
69+ }
70+ e.filePath = p
71+ f, warnings, err := kvconf.Load[cliFile](p, kvconf.Options{})
72+ if err != nil {
73+ e.fileErr = fmt.Errorf("config: %w", err)
74+ return
75+ }
76+ if warn {
77+ for _, w := range warnings {
78+ warnf(cmd, "config: %s", w)
79+ }
80+ }
81+ e.file = f
82+ })
83+ return e.file, e.filePath, e.fileErr
84+}
85+
86+// resolveURL is ERMITE_URL if set, else the url of the config file, else "".
87+func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) {
88+ if v := strings.TrimSpace(e.getenv(client.EnvURL)); v != "" {
89+ return v, nil
90+ }
91+ f, _, err := e.readFile(cmd, warn)
92+ if err != nil {
93+ return "", err
94+ }
95+ return strings.TrimSpace(f.URL), nil
96+}
97+
98+// resolveToken is ERMITE_TOKEN if set, else (when runCmd is set) the output
99+// of token.cmd, else "" (anonymous). token.cmd runs at most once per process.
100+func (e *cliEnv) resolveToken(cmd *cobra.Command, warn, runCmd bool) (string, error) {
101+ if v := strings.TrimSpace(e.getenv(client.EnvToken)); v != "" {
102+ return v, nil
103+ }
104+ if !runCmd {
105+ return "", nil
106+ }
107+ return e.tokenFromCmd(cmd, warn)
108+}
109+
110+// tokenFromCmd is the output of token.cmd, or "" when the file sets none.
111+func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) {
112+ f, path, err := e.readFile(cmd, warn)
113+ if err != nil {
114+ return "", err
115+ }
116+ line := strings.TrimSpace(f.TokenCmd)
117+ if line == "" {
118+ return "", nil
119+ }
120+ e.tokenOnce.Do(func() { e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), path, line) })
121+ return e.token, e.tokenErr
122+}
123+
124+// fetchToken runs token.cmd. Neither its output nor its error text reach the
125+// caller's messages: the output is the secret.
126+func (e *cliEnv) fetchToken(ctx context.Context, path, line string) (string, error) {
127+ if err := requirePrivate(path); err != nil {
128+ return "", err
129+ }
130+ run := e.runTokenCmd
131+ if run == nil {
132+ run = runShell
133+ }
134+ out, err := run(ctx, line)
135+ if err != nil {
136+ return "", fmt.Errorf("token.cmd failed: %w", err)
137+ }
138+ out = strings.TrimSpace(out)
139+ switch {
140+ case out == "":
141+ return "", errors.New("token.cmd printed nothing")
142+ case strings.ContainsAny(out, "\r\n"):
143+ return "", errors.New("token.cmd printed more than one line")
144+ }
145+ return out, nil
146+}
147+
148+// requirePrivate refuses a config file other users can write: they could
149+// change token.cmd into any command. Windows has no such mode bits.
150+func requirePrivate(path string) error {
151+ if runtime.GOOS == "windows" {
152+ return nil
153+ }
154+ info, err := os.Stat(path)
155+ if err != nil {
156+ return fmt.Errorf("config: %w", err)
157+ }
158+ if info.Mode().Perm()&0o022 != 0 {
159+ return fmt.Errorf("config file %s is writable by others: token.cmd not run", path)
160+ }
161+ return nil
162+}
163+
164+// runShell runs line with the system shell. Standard input and error are the
165+// terminal's, so a pinentry or a biometric prompt works; standard output is
166+// captured, up to tokenCmdMaxOutput bytes. The environment is the process's.
167+func runShell(ctx context.Context, line string) (string, error) {
168+ ctx, cancel := context.WithTimeout(ctx, tokenCmdTimeout)
169+ defer cancel()
170+ c := shellCommand(ctx, line)
171+ out := &cappedBuffer{max: tokenCmdMaxOutput}
172+ c.Stdin, c.Stdout, c.Stderr = os.Stdin, out, os.Stderr
173+ // Do not wait forever for a background process that kept the pipe open.
174+ c.WaitDelay = 2 * time.Second
175+ err := c.Run()
176+ switch {
177+ case errors.Is(ctx.Err(), context.DeadlineExceeded):
178+ return "", fmt.Errorf("timed out after %s", tokenCmdTimeout)
179+ case err != nil:
180+ var exit *exec.ExitError
181+ if errors.As(err, &exit) {
182+ return "", errors.New(exit.ProcessState.String())
183+ }
184+ return "", err
185+ case out.over:
186+ return "", fmt.Errorf("printed more than %d bytes", tokenCmdMaxOutput)
187+ }
188+ return out.buf.String(), nil
189+}
190+
191+// cappedBuffer keeps the first max bytes written and notes an overflow.
192+type cappedBuffer struct {
193+ buf bytes.Buffer
194+ max int
195+ over bool
196+}
197+
198+func (b *cappedBuffer) Write(p []byte) (int, error) {
199+ n := len(p) // report everything as written: a short write would abort the copy
200+ if room := b.max - b.buf.Len(); len(p) > room {
201+ b.over = true
202+ p = p[:room]
203+ }
204+ b.buf.Write(p)
205+ return n, nil
206+}
207+
208+var _ io.Writer = (*cappedBuffer)(nil)
@@ -0,0 +1,208 @@
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+ "os"
12+ "os/exec"
13+ "runtime"
14+ "strings"
15+ "time"
16+
17+ "github.com/bnema/kvconf"
18+ "github.com/spf13/cobra"
19+
20+ "bnema.dev/ermite/internal/adapters/client"
21+)
22+
23+const (
24+ // envConfig selects the CLI config file instead of ~/.config/ermite/config.
25+ envConfig = "ERMITE_CONFIG"
26+ // Keys of the CLI config file.
27+ keyURL = "url"
28+ keyTokenCmd = "token.cmd"
29+
30+ // tokenCmdTimeout bounds one run of token.cmd, prompts included.
31+ tokenCmdTimeout = 60 * time.Second
32+ // tokenCmdMaxOutput bounds what is read from its standard output.
33+ tokenCmdMaxOutput = 4 << 10
34+)
35+
36+// cliFile is the CLI config file. It holds no secret: the token is fetched
37+// from a secret manager by the token.cmd command line.
38+type cliFile struct {
39+ URL string `kv:"url"`
40+ TokenCmd string `kv:"token.cmd"`
41+}
42+
43+// path is the config file: $ERMITE_CONFIG, else <config dir>/ermite/config.
44+func (e *cliEnv) path() (string, error) {
45+ if e.configPath != nil {
46+ return e.configPath()
47+ }
48+ if p := strings.TrimSpace(e.getenv(envConfig)); p != "" {
49+ return p, nil
50+ }
51+ p, err := kvconf.DefaultPath("ermite", "config")
52+ if err != nil {
53+ return "", fmt.Errorf("no home directory to find the config file: set %s", envConfig)
54+ }
55+ return p, nil
56+}
57+
58+// readFile reads the config file, once per process. A missing file, or no
59+// place to look for it, gives an empty one. Problems in the file are warnings
60+// on stderr, printed once, and only when warn is set.
61+func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, error) {
62+ e.fileOnce.Do(func() {
63+ p, err := e.path()
64+ if err != nil {
65+ // Reading is best effort when the location is unknown; the
66+ // `ermite config` commands report this error.
67+ e.file = &cliFile{}
68+ return
69+ }
70+ e.filePath = p
71+ f, warnings, err := kvconf.Load[cliFile](p, kvconf.Options{})
72+ if err != nil {
73+ e.fileErr = fmt.Errorf("config: %w", err)
74+ return
75+ }
76+ if warn {
77+ for _, w := range warnings {
78+ warnf(cmd, "config: %s", w)
79+ }
80+ }
81+ e.file = f
82+ })
83+ return e.file, e.filePath, e.fileErr
84+}
85+
86+// resolveURL is ERMITE_URL if set, else the url of the config file, else "".
87+func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) {
88+ if v := strings.TrimSpace(e.getenv(client.EnvURL)); v != "" {
89+ return v, nil
90+ }
91+ f, _, err := e.readFile(cmd, warn)
92+ if err != nil {
93+ return "", err
94+ }
95+ return strings.TrimSpace(f.URL), nil
96+}
97+
98+// resolveToken is ERMITE_TOKEN if set, else (when runCmd is set) the output
99+// of token.cmd, else "" (anonymous). token.cmd runs at most once per process.
100+func (e *cliEnv) resolveToken(cmd *cobra.Command, warn, runCmd bool) (string, error) {
101+ if v := strings.TrimSpace(e.getenv(client.EnvToken)); v != "" {
102+ return v, nil
103+ }
104+ if !runCmd {
105+ return "", nil
106+ }
107+ return e.tokenFromCmd(cmd, warn)
108+}
109+
110+// tokenFromCmd is the output of token.cmd, or "" when the file sets none.
111+func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) {
112+ f, path, err := e.readFile(cmd, warn)
113+ if err != nil {
114+ return "", err
115+ }
116+ line := strings.TrimSpace(f.TokenCmd)
117+ if line == "" {
118+ return "", nil
119+ }
120+ e.tokenOnce.Do(func() { e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), path, line) })
121+ return e.token, e.tokenErr
122+}
123+
124+// fetchToken runs token.cmd. Neither its output nor its error text reach the
125+// caller's messages: the output is the secret.
126+func (e *cliEnv) fetchToken(ctx context.Context, path, line string) (string, error) {
127+ if err := requirePrivate(path); err != nil {
128+ return "", err
129+ }
130+ run := e.runTokenCmd
131+ if run == nil {
132+ run = runShell
133+ }
134+ out, err := run(ctx, line)
135+ if err != nil {
136+ return "", fmt.Errorf("token.cmd failed: %w", err)
137+ }
138+ out = strings.TrimSpace(out)
139+ switch {
140+ case out == "":
141+ return "", errors.New("token.cmd printed nothing")
142+ case strings.ContainsAny(out, "\r\n"):
143+ return "", errors.New("token.cmd printed more than one line")
144+ }
145+ return out, nil
146+}
147+
148+// requirePrivate refuses a config file other users can write: they could
149+// change token.cmd into any command. Windows has no such mode bits.
150+func requirePrivate(path string) error {
151+ if runtime.GOOS == "windows" {
152+ return nil
153+ }
154+ info, err := os.Stat(path)
155+ if err != nil {
156+ return fmt.Errorf("config: %w", err)
157+ }
158+ if info.Mode().Perm()&0o022 != 0 {
159+ return fmt.Errorf("config file %s is writable by others: token.cmd not run", path)
160+ }
161+ return nil
162+}
163+
164+// runShell runs line with the system shell. Standard input and error are the
165+// terminal's, so a pinentry or a biometric prompt works; standard output is
166+// captured, up to tokenCmdMaxOutput bytes. The environment is the process's.
167+func runShell(ctx context.Context, line string) (string, error) {
168+ ctx, cancel := context.WithTimeout(ctx, tokenCmdTimeout)
169+ defer cancel()
170+ c := shellCommand(ctx, line)
171+ out := &cappedBuffer{max: tokenCmdMaxOutput}
172+ c.Stdin, c.Stdout, c.Stderr = os.Stdin, out, os.Stderr
173+ // Do not wait forever for a background process that kept the pipe open.
174+ c.WaitDelay = 2 * time.Second
175+ err := c.Run()
176+ switch {
177+ case errors.Is(ctx.Err(), context.DeadlineExceeded):
178+ return "", fmt.Errorf("timed out after %s", tokenCmdTimeout)
179+ case err != nil:
180+ var exit *exec.ExitError
181+ if errors.As(err, &exit) {
182+ return "", errors.New(exit.ProcessState.String())
183+ }
184+ return "", err
185+ case out.over:
186+ return "", fmt.Errorf("printed more than %d bytes", tokenCmdMaxOutput)
187+ }
188+ return out.buf.String(), nil
189+}
190+
191+// cappedBuffer keeps the first max bytes written and notes an overflow.
192+type cappedBuffer struct {
193+ buf bytes.Buffer
194+ max int
195+ over bool
196+}
197+
198+func (b *cappedBuffer) Write(p []byte) (int, error) {
199+ n := len(p) // report everything as written: a short write would abort the copy
200+ if room := b.max - b.buf.Len(); len(p) > room {
201+ b.over = true
202+ p = p[:room]
203+ }
204+ b.buf.Write(p)
205+ return n, nil
206+}
207+
208+var _ io.Writer = (*cappedBuffer)(nil)
A internal/app/cli_configfile_test.go +293 -0
@@ -0,0 +1,293 @@
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+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite-api/gen/ermite"
18+)
19+
20+// writeConfigFile writes the config file of env with the given mode.
21+func writeConfigFile(t *testing.T, env *cliEnv, content string, mode os.FileMode) string {
22+ t.Helper()
23+ path, err := env.configPath()
24+ require.NoError(t, err)
25+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
26+ require.NoError(t, os.WriteFile(path, []byte(content), mode))
27+ require.NoError(t, os.Chmod(path, mode)) // umask
28+ return path
29+}
30+
31+// tokenCmdStub makes runTokenCmd return out and err, and counts the runs.
32+func tokenCmdStub(env *cliEnv, out string, err error) *[]string {
33+ var ran []string
34+ env.runTokenCmd = func(_ context.Context, line string) (string, error) {
35+ ran = append(ran, line)
36+ return out, err
37+ }
38+ return &ran
39+}
40+
41+func listFake() *fakeAPI {
42+ return &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
43+ return &ermite.ListReposResponse{}, nil
44+ }}
45+}
46+
47+func lastAuth(f *fakeAPI) string {
48+ f.mu.Lock()
49+ defer f.mu.Unlock()
50+ if len(f.header) == 0 {
51+ return "<no call>"
52+ }
53+ return f.header[len(f.header)-1].Get("Authorization")
54+}
55+
56+func TestConfigFileSuppliesURLAndToken(t *testing.T) {
57+ f := listFake()
58+ url := f.serve(t)
59+ env := testEnv(t, nil)
60+ writeConfigFile(t, env, "# my server\nurl = "+url+"\ntoken.cmd = pass show ermite/token\n", 0o600)
61+ ran := tokenCmdStub(env, " ermite_fromcmd\n", nil)
62+
63+ runCLIWith(t, env, "", "repo", "list").ok(t)
64+ assert.Equal(t, "Bearer ermite_fromcmd", lastAuth(f))
65+ assert.Equal(t, []string{"pass show ermite/token"}, *ran)
66+}
67+
68+func TestConfigFileURLWithoutTokenCmdIsAnonymous(t *testing.T) {
69+ f := listFake()
70+ env := testEnv(t, nil)
71+ writeConfigFile(t, env, "url = "+f.serve(t)+"\n", 0o600)
72+
73+ runCLIWith(t, env, "", "repo", "list").ok(t)
74+ assert.Empty(t, lastAuth(f))
75+}
76+
77+func TestEnvironmentWinsOverConfigFile(t *testing.T) {
78+ fileSrv, envSrv := listFake(), listFake()
79+ env := testEnv(t, map[string]string{"ERMITE_URL": envSrv.serve(t), "ERMITE_TOKEN": "ermite_env"})
80+ writeConfigFile(t, env, "url = "+fileSrv.serve(t)+"\ntoken.cmd = echo nope\n", 0o600)
81+
82+ runCLIWith(t, env, "", "repo", "list").ok(t) // the default runTokenCmd of testEnv fails the test if called
83+ assert.Equal(t, "Bearer ermite_env", lastAuth(envSrv))
84+ calls, _ := fileSrv.snapshot()
85+ assert.Empty(t, calls, "the url of the file is not used when ERMITE_URL is set")
86+}
87+
88+func TestEnvURLAloneStillUsesTokenCmd(t *testing.T) {
89+ f := listFake()
90+ env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t)})
91+ writeConfigFile(t, env, "url = http://file.invalid\ntoken.cmd = get-token\n", 0o600)
92+ ran := tokenCmdStub(env, "ermite_cmd", nil)
93+
94+ runCLIWith(t, env, "", "repo", "list").ok(t)
95+ assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
96+ assert.Len(t, *ran, 1)
97+}
98+
99+func TestTokenCmdNotRunWhenEnvTokenSet(t *testing.T) {
100+ f := listFake()
101+ env := testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"})
102+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o666) // even an unsafe file is not an issue
103+ ran := tokenCmdStub(env, "other", nil)
104+
105+ runCLIWith(t, env, "", "repo", "list").ok(t)
106+ assert.Empty(t, *ran)
107+ assert.Equal(t, "Bearer ermite_env", lastAuth(f))
108+}
109+
110+func TestTokenCmdNotRunForVersionNorCompletion(t *testing.T) {
111+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
112+ return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil
113+ }}
114+ url := f.serve(t)
115+
116+ for name, args := range map[string][]string{
117+ "version": {"version"},
118+ "version --json": {"version", "--json"},
119+ "completion": {"completion", "bash"},
120+ } {
121+ t.Run(name, func(t *testing.T) {
122+ env := testEnv(t, nil)
123+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
124+ ran := tokenCmdStub(env, "", errors.New("must not run"))
125+ runCLIWith(t, env, "", args...).ok(t)
126+ assert.Empty(t, *ran)
127+ })
128+ }
129+
130+ t.Run("version without any url", func(t *testing.T) {
131+ env := testEnv(t, nil)
132+ writeConfigFile(t, env, "token.cmd = get-token\n", 0o600)
133+ ran := tokenCmdStub(env, "", errors.New("must not run"))
134+ res := runCLIWith(t, env, "", "version").ok(t)
135+ assert.Equal(t, "ermite v0.0.1\n", res.stdout)
136+ assert.Empty(t, *ran)
137+ })
138+
139+ t.Run("version uses the url of the file and sends no token", func(t *testing.T) {
140+ env := testEnv(t, nil)
141+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
142+ tokenCmdStub(env, "ermite_cmd", nil)
143+ res := runCLIWith(t, env, "", "version").ok(t)
144+ assert.Contains(t, res.stdout, "v0.4.0 at "+url)
145+ assert.Empty(t, lastAuth(f))
146+ })
147+}
148+
149+func TestTokenCmdRunsOncePerProcess(t *testing.T) {
150+ f := listFake()
151+ env := testEnv(t, nil)
152+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
153+ ran := tokenCmdStub(env, "ermite_cmd", nil)
154+ root := newRootCmdWith(env)
155+ root.SetArgs([]string{"repo", "list"})
156+ for range 2 {
157+ c, _, err := root.Find([]string{"repo", "list"})
158+ require.NoError(t, err)
159+ _, err = env.client(c, true)
160+ require.NoError(t, err)
161+ }
162+ assert.Len(t, *ran, 1)
163+}
164+
165+func TestTokenCmdRefusedWhenConfigFileIsWritableByOthers(t *testing.T) {
166+ if runtime.GOOS == "windows" {
167+ t.Skip("no Unix permission bits")
168+ }
169+ for _, mode := range []os.FileMode{0o666, 0o662, 0o620} {
170+ t.Run(mode.String(), func(t *testing.T) {
171+ env := testEnv(t, nil)
172+ path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", mode)
173+ ran := tokenCmdStub(env, "ermite_cmd", nil)
174+ res := runCLIWith(t, env, "", "repo", "list")
175+ require.Error(t, res.err)
176+ assert.Equal(t, "config file "+path+" is writable by others: token.cmd not run", res.err.Error())
177+ assert.Empty(t, *ran)
178+ })
179+ }
180+ t.Run("group readable is fine", func(t *testing.T) {
181+ f := listFake()
182+ env := testEnv(t, nil)
183+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o644)
184+ tokenCmdStub(env, "ermite_cmd", nil)
185+ runCLIWith(t, env, "", "repo", "list").ok(t)
186+ })
187+}
188+
189+func TestTokenCmdFailuresNeverShowOutput(t *testing.T) {
190+ const secret = "s3cret-token-value"
191+ tests := []struct {
192+ name string
193+ out string
194+ err error
195+ want string
196+ }{
197+ {"failure", secret, errors.New("exit status 3"), "token.cmd failed: exit status 3"},
198+ {"empty", " \n", nil, "token.cmd printed nothing"},
199+ {"multiline", secret + "\n" + secret + "\n", nil, "token.cmd printed more than one line"},
200+ {"crlf multiline", secret + "\r\n" + secret, nil, "token.cmd printed more than one line"},
201+ }
202+ for _, tt := range tests {
203+ t.Run(tt.name, func(t *testing.T) {
204+ env := testEnv(t, nil)
205+ writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600)
206+ tokenCmdStub(env, tt.out, tt.err)
207+ res := runCLIWith(t, env, "", "repo", "list")
208+ require.Error(t, res.err)
209+ assert.Equal(t, tt.want, res.err.Error())
210+ assert.NotContains(t, res.err.Error()+res.stdout+res.stderr, secret)
211+ })
212+ }
213+}
214+
215+func TestMissingURLIsReportedBeforeTokenCmdRuns(t *testing.T) {
216+ env := testEnv(t, nil)
217+ writeConfigFile(t, env, "token.cmd = get-token\n", 0o600)
218+ ran := tokenCmdStub(env, "ermite_cmd", nil)
219+ res := runCLIWith(t, env, "", "repo", "list")
220+ require.Error(t, res.err)
221+ assert.Contains(t, res.err.Error(), "ERMITE_URL is not set")
222+ assert.Contains(t, res.err.Error(), "ermite config set url")
223+ assert.Empty(t, *ran)
224+}
225+
226+func TestInvalidFileURLIsReported(t *testing.T) {
227+ env := testEnv(t, nil)
228+ writeConfigFile(t, env, "url = git.example.com\n", 0o600)
229+ res := runCLIWith(t, env, "", "repo", "list")
230+ require.Error(t, res.err)
231+ assert.Contains(t, res.err.Error(), "invalid server URL")
232+ assert.Contains(t, res.err.Error(), "http")
233+}
234+
235+func TestConfigFileWarnings(t *testing.T) {
236+ f := listFake()
237+ env := testEnv(t, nil)
238+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ncolour = blue\n", 0o600)
239+ res := runCLIWith(t, env, "", "repo", "list").ok(t)
240+ assert.Equal(t, 1, strings.Count(res.stderr, "ermite: warning: config: "), res.stderr)
241+ assert.Contains(t, res.stderr, "colour")
242+ assert.NotContains(t, res.stderr, "blue", "values are never echoed")
243+}
244+
245+func TestCompletionStaysSilentAboutTheConfigFile(t *testing.T) {
246+ env := testEnv(t, nil)
247+ writeConfigFile(t, env, "colour = blue\nurl = nope\n", 0o600)
248+ res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
249+ assert.NotContains(t, res.stderr, "warning")
250+}
251+
252+func TestERMITE_CONFIGSelectsTheFile(t *testing.T) {
253+ f := listFake()
254+ path := filepath.Join(t.TempDir(), "other", "conf")
255+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
256+ require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\n"), 0o600))
257+
258+ env := testEnv(t, map[string]string{"ERMITE_CONFIG": path})
259+ env.configPath = nil // the default resolution, which reads ERMITE_CONFIG
260+ runCLIWith(t, env, "", "repo", "list").ok(t)
261+}
262+
263+func TestTokenCmdRunsThroughTheShell(t *testing.T) {
264+ if runtime.GOOS == "windows" {
265+ t.Skip("uses sh")
266+ }
267+ f := listFake()
268+ env := testEnv(t, nil)
269+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = printf secret\n", 0o600)
270+ env.runTokenCmd = nil // the real thing
271+
272+ runCLIWith(t, env, "", "repo", "list").ok(t)
273+ assert.Equal(t, "Bearer secret", lastAuth(f))
274+}
275+
276+func TestRunShellReportsFailuresWithoutOutput(t *testing.T) {
277+ if runtime.GOOS == "windows" {
278+ t.Skip("uses sh")
279+ }
280+ _, err := runShell(t.Context(), "echo s3cret; exit 3")
281+ require.Error(t, err)
282+ assert.Contains(t, err.Error(), "exit status 3")
283+ assert.NotContains(t, err.Error(), "s3cret")
284+
285+ _, err = runShell(t.Context(), "head -c 5000 /dev/zero | tr '\\0' a")
286+ require.Error(t, err)
287+ assert.Contains(t, err.Error(), "more than 4096 bytes")
288+ assert.NotContains(t, err.Error(), "aaa")
289+
290+ out, err := runShell(t.Context(), "echo ermite_ok")
291+ require.NoError(t, err)
292+ assert.Equal(t, "ermite_ok\n", out)
293+}
@@ -0,0 +1,293 @@
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+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite-api/gen/ermite"
18+)
19+
20+// writeConfigFile writes the config file of env with the given mode.
21+func writeConfigFile(t *testing.T, env *cliEnv, content string, mode os.FileMode) string {
22+ t.Helper()
23+ path, err := env.configPath()
24+ require.NoError(t, err)
25+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
26+ require.NoError(t, os.WriteFile(path, []byte(content), mode))
27+ require.NoError(t, os.Chmod(path, mode)) // umask
28+ return path
29+}
30+
31+// tokenCmdStub makes runTokenCmd return out and err, and counts the runs.
32+func tokenCmdStub(env *cliEnv, out string, err error) *[]string {
33+ var ran []string
34+ env.runTokenCmd = func(_ context.Context, line string) (string, error) {
35+ ran = append(ran, line)
36+ return out, err
37+ }
38+ return &ran
39+}
40+
41+func listFake() *fakeAPI {
42+ return &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
43+ return &ermite.ListReposResponse{}, nil
44+ }}
45+}
46+
47+func lastAuth(f *fakeAPI) string {
48+ f.mu.Lock()
49+ defer f.mu.Unlock()
50+ if len(f.header) == 0 {
51+ return "<no call>"
52+ }
53+ return f.header[len(f.header)-1].Get("Authorization")
54+}
55+
56+func TestConfigFileSuppliesURLAndToken(t *testing.T) {
57+ f := listFake()
58+ url := f.serve(t)
59+ env := testEnv(t, nil)
60+ writeConfigFile(t, env, "# my server\nurl = "+url+"\ntoken.cmd = pass show ermite/token\n", 0o600)
61+ ran := tokenCmdStub(env, " ermite_fromcmd\n", nil)
62+
63+ runCLIWith(t, env, "", "repo", "list").ok(t)
64+ assert.Equal(t, "Bearer ermite_fromcmd", lastAuth(f))
65+ assert.Equal(t, []string{"pass show ermite/token"}, *ran)
66+}
67+
68+func TestConfigFileURLWithoutTokenCmdIsAnonymous(t *testing.T) {
69+ f := listFake()
70+ env := testEnv(t, nil)
71+ writeConfigFile(t, env, "url = "+f.serve(t)+"\n", 0o600)
72+
73+ runCLIWith(t, env, "", "repo", "list").ok(t)
74+ assert.Empty(t, lastAuth(f))
75+}
76+
77+func TestEnvironmentWinsOverConfigFile(t *testing.T) {
78+ fileSrv, envSrv := listFake(), listFake()
79+ env := testEnv(t, map[string]string{"ERMITE_URL": envSrv.serve(t), "ERMITE_TOKEN": "ermite_env"})
80+ writeConfigFile(t, env, "url = "+fileSrv.serve(t)+"\ntoken.cmd = echo nope\n", 0o600)
81+
82+ runCLIWith(t, env, "", "repo", "list").ok(t) // the default runTokenCmd of testEnv fails the test if called
83+ assert.Equal(t, "Bearer ermite_env", lastAuth(envSrv))
84+ calls, _ := fileSrv.snapshot()
85+ assert.Empty(t, calls, "the url of the file is not used when ERMITE_URL is set")
86+}
87+
88+func TestEnvURLAloneStillUsesTokenCmd(t *testing.T) {
89+ f := listFake()
90+ env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t)})
91+ writeConfigFile(t, env, "url = http://file.invalid\ntoken.cmd = get-token\n", 0o600)
92+ ran := tokenCmdStub(env, "ermite_cmd", nil)
93+
94+ runCLIWith(t, env, "", "repo", "list").ok(t)
95+ assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
96+ assert.Len(t, *ran, 1)
97+}
98+
99+func TestTokenCmdNotRunWhenEnvTokenSet(t *testing.T) {
100+ f := listFake()
101+ env := testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"})
102+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o666) // even an unsafe file is not an issue
103+ ran := tokenCmdStub(env, "other", nil)
104+
105+ runCLIWith(t, env, "", "repo", "list").ok(t)
106+ assert.Empty(t, *ran)
107+ assert.Equal(t, "Bearer ermite_env", lastAuth(f))
108+}
109+
110+func TestTokenCmdNotRunForVersionNorCompletion(t *testing.T) {
111+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
112+ return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil
113+ }}
114+ url := f.serve(t)
115+
116+ for name, args := range map[string][]string{
117+ "version": {"version"},
118+ "version --json": {"version", "--json"},
119+ "completion": {"completion", "bash"},
120+ } {
121+ t.Run(name, func(t *testing.T) {
122+ env := testEnv(t, nil)
123+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
124+ ran := tokenCmdStub(env, "", errors.New("must not run"))
125+ runCLIWith(t, env, "", args...).ok(t)
126+ assert.Empty(t, *ran)
127+ })
128+ }
129+
130+ t.Run("version without any url", func(t *testing.T) {
131+ env := testEnv(t, nil)
132+ writeConfigFile(t, env, "token.cmd = get-token\n", 0o600)
133+ ran := tokenCmdStub(env, "", errors.New("must not run"))
134+ res := runCLIWith(t, env, "", "version").ok(t)
135+ assert.Equal(t, "ermite v0.0.1\n", res.stdout)
136+ assert.Empty(t, *ran)
137+ })
138+
139+ t.Run("version uses the url of the file and sends no token", func(t *testing.T) {
140+ env := testEnv(t, nil)
141+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
142+ tokenCmdStub(env, "ermite_cmd", nil)
143+ res := runCLIWith(t, env, "", "version").ok(t)
144+ assert.Contains(t, res.stdout, "v0.4.0 at "+url)
145+ assert.Empty(t, lastAuth(f))
146+ })
147+}
148+
149+func TestTokenCmdRunsOncePerProcess(t *testing.T) {
150+ f := listFake()
151+ env := testEnv(t, nil)
152+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
153+ ran := tokenCmdStub(env, "ermite_cmd", nil)
154+ root := newRootCmdWith(env)
155+ root.SetArgs([]string{"repo", "list"})
156+ for range 2 {
157+ c, _, err := root.Find([]string{"repo", "list"})
158+ require.NoError(t, err)
159+ _, err = env.client(c, true)
160+ require.NoError(t, err)
161+ }
162+ assert.Len(t, *ran, 1)
163+}
164+
165+func TestTokenCmdRefusedWhenConfigFileIsWritableByOthers(t *testing.T) {
166+ if runtime.GOOS == "windows" {
167+ t.Skip("no Unix permission bits")
168+ }
169+ for _, mode := range []os.FileMode{0o666, 0o662, 0o620} {
170+ t.Run(mode.String(), func(t *testing.T) {
171+ env := testEnv(t, nil)
172+ path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", mode)
173+ ran := tokenCmdStub(env, "ermite_cmd", nil)
174+ res := runCLIWith(t, env, "", "repo", "list")
175+ require.Error(t, res.err)
176+ assert.Equal(t, "config file "+path+" is writable by others: token.cmd not run", res.err.Error())
177+ assert.Empty(t, *ran)
178+ })
179+ }
180+ t.Run("group readable is fine", func(t *testing.T) {
181+ f := listFake()
182+ env := testEnv(t, nil)
183+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o644)
184+ tokenCmdStub(env, "ermite_cmd", nil)
185+ runCLIWith(t, env, "", "repo", "list").ok(t)
186+ })
187+}
188+
189+func TestTokenCmdFailuresNeverShowOutput(t *testing.T) {
190+ const secret = "s3cret-token-value"
191+ tests := []struct {
192+ name string
193+ out string
194+ err error
195+ want string
196+ }{
197+ {"failure", secret, errors.New("exit status 3"), "token.cmd failed: exit status 3"},
198+ {"empty", " \n", nil, "token.cmd printed nothing"},
199+ {"multiline", secret + "\n" + secret + "\n", nil, "token.cmd printed more than one line"},
200+ {"crlf multiline", secret + "\r\n" + secret, nil, "token.cmd printed more than one line"},
201+ }
202+ for _, tt := range tests {
203+ t.Run(tt.name, func(t *testing.T) {
204+ env := testEnv(t, nil)
205+ writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600)
206+ tokenCmdStub(env, tt.out, tt.err)
207+ res := runCLIWith(t, env, "", "repo", "list")
208+ require.Error(t, res.err)
209+ assert.Equal(t, tt.want, res.err.Error())
210+ assert.NotContains(t, res.err.Error()+res.stdout+res.stderr, secret)
211+ })
212+ }
213+}
214+
215+func TestMissingURLIsReportedBeforeTokenCmdRuns(t *testing.T) {
216+ env := testEnv(t, nil)
217+ writeConfigFile(t, env, "token.cmd = get-token\n", 0o600)
218+ ran := tokenCmdStub(env, "ermite_cmd", nil)
219+ res := runCLIWith(t, env, "", "repo", "list")
220+ require.Error(t, res.err)
221+ assert.Contains(t, res.err.Error(), "ERMITE_URL is not set")
222+ assert.Contains(t, res.err.Error(), "ermite config set url")
223+ assert.Empty(t, *ran)
224+}
225+
226+func TestInvalidFileURLIsReported(t *testing.T) {
227+ env := testEnv(t, nil)
228+ writeConfigFile(t, env, "url = git.example.com\n", 0o600)
229+ res := runCLIWith(t, env, "", "repo", "list")
230+ require.Error(t, res.err)
231+ assert.Contains(t, res.err.Error(), "invalid server URL")
232+ assert.Contains(t, res.err.Error(), "http")
233+}
234+
235+func TestConfigFileWarnings(t *testing.T) {
236+ f := listFake()
237+ env := testEnv(t, nil)
238+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ncolour = blue\n", 0o600)
239+ res := runCLIWith(t, env, "", "repo", "list").ok(t)
240+ assert.Equal(t, 1, strings.Count(res.stderr, "ermite: warning: config: "), res.stderr)
241+ assert.Contains(t, res.stderr, "colour")
242+ assert.NotContains(t, res.stderr, "blue", "values are never echoed")
243+}
244+
245+func TestCompletionStaysSilentAboutTheConfigFile(t *testing.T) {
246+ env := testEnv(t, nil)
247+ writeConfigFile(t, env, "colour = blue\nurl = nope\n", 0o600)
248+ res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
249+ assert.NotContains(t, res.stderr, "warning")
250+}
251+
252+func TestERMITE_CONFIGSelectsTheFile(t *testing.T) {
253+ f := listFake()
254+ path := filepath.Join(t.TempDir(), "other", "conf")
255+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
256+ require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\n"), 0o600))
257+
258+ env := testEnv(t, map[string]string{"ERMITE_CONFIG": path})
259+ env.configPath = nil // the default resolution, which reads ERMITE_CONFIG
260+ runCLIWith(t, env, "", "repo", "list").ok(t)
261+}
262+
263+func TestTokenCmdRunsThroughTheShell(t *testing.T) {
264+ if runtime.GOOS == "windows" {
265+ t.Skip("uses sh")
266+ }
267+ f := listFake()
268+ env := testEnv(t, nil)
269+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = printf secret\n", 0o600)
270+ env.runTokenCmd = nil // the real thing
271+
272+ runCLIWith(t, env, "", "repo", "list").ok(t)
273+ assert.Equal(t, "Bearer secret", lastAuth(f))
274+}
275+
276+func TestRunShellReportsFailuresWithoutOutput(t *testing.T) {
277+ if runtime.GOOS == "windows" {
278+ t.Skip("uses sh")
279+ }
280+ _, err := runShell(t.Context(), "echo s3cret; exit 3")
281+ require.Error(t, err)
282+ assert.Contains(t, err.Error(), "exit status 3")
283+ assert.NotContains(t, err.Error(), "s3cret")
284+
285+ _, err = runShell(t.Context(), "head -c 5000 /dev/zero | tr '\\0' a")
286+ require.Error(t, err)
287+ assert.Contains(t, err.Error(), "more than 4096 bytes")
288+ assert.NotContains(t, err.Error(), "aaa")
289+
290+ out, err := runShell(t.Context(), "echo ermite_ok")
291+ require.NoError(t, err)
292+ assert.Equal(t, "ermite_ok\n", out)
293+}
M internal/app/cli_fake_test.go +28 -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"
@@ -173,15 +175,35 @@ type result struct {
173175 err error
174176 }
175177
178+// testEnv is a cliEnv with the given variables, the fixed clock, and a config
179+// file path inside a fresh temp dir (no file yet) so that a test never reads
180+// the developer's own ~/.config/ermite/config. token.cmd must not run unless
181+// the test sets runTokenCmd.
182+func testEnv(t *testing.T, vars map[string]string) *cliEnv {
183+ t.Helper()
184+ path := filepath.Join(t.TempDir(), "ermite", "config")
185+ return &cliEnv{
186+ getenv: func(k string) string { return vars[k] },
187+ now: func() time.Time { return testNow },
188+ version: "v0.0.1",
189+ sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() },
190+ configPath: func() (string, error) { return path, nil },
191+ runTokenCmd: func(context.Context, string) (string, error) {
192+ t.Error("token.cmd must not run")
193+ return "", errors.New("token.cmd must not run")
194+ },
195+ }
196+}
197+
176198 // runCLI runs the command tree in process with the given environment.
177199 func runCLI(t *testing.T, vars map[string]string, stdin string, args ...string) result {
178200 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- }
201+ return runCLIWith(t, testEnv(t, vars), stdin, args...)
202+}
203+
204+// runCLIWith runs the command tree in process with env.
205+func runCLIWith(t *testing.T, env *cliEnv, stdin string, args ...string) result {
206+ t.Helper()
185207 root := newRootCmdWith(env)
186208 var out, errb bytes.Buffer
187209 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"
@@ -173,15 +175,35 @@ type result struct {
173 err error175 err error
174 }176 }
175 177
178+// testEnv is a cliEnv with the given variables, the fixed clock, and a config
179+// file path inside a fresh temp dir (no file yet) so that a test never reads
180+// the developer's own ~/.config/ermite/config. token.cmd must not run unless
181+// the test sets runTokenCmd.
182+func testEnv(t *testing.T, vars map[string]string) *cliEnv {
183+ t.Helper()
184+ path := filepath.Join(t.TempDir(), "ermite", "config")
185+ return &cliEnv{
186+ getenv: func(k string) string { return vars[k] },
187+ now: func() time.Time { return testNow },
188+ version: "v0.0.1",
189+ sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() },
190+ configPath: func() (string, error) { return path, nil },
191+ runTokenCmd: func(context.Context, string) (string, error) {
192+ t.Error("token.cmd must not run")
193+ return "", errors.New("token.cmd must not run")
194+ },
195+ }
196+}
197+
176 // runCLI runs the command tree in process with the given environment.198 // 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 {199 func runCLI(t *testing.T, vars map[string]string, stdin string, args ...string) result {
178 t.Helper()200 t.Helper()
179- env := &cliEnv{201+ return runCLIWith(t, testEnv(t, vars), stdin, args...)
180- getenv: func(k string) string { return vars[k] },202+}
181- now: func() time.Time { return testNow },203+
182- version: "v0.0.1",204+// runCLIWith runs the command tree in process with env.
183- sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() },205+func runCLIWith(t *testing.T, env *cliEnv, stdin string, args ...string) result {
184- }206+ t.Helper()
185 root := newRootCmdWith(env)207 root := newRootCmdWith(env)
186 var out, errb bytes.Buffer208 var out, errb bytes.Buffer
187 root.SetOut(&out)209 root.SetOut(&out)
A internal/app/cli_shell_unix.go +15 -0
@@ -0,0 +1,15 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build !windows
4+
5+package app
6+
7+import (
8+ "context"
9+ "os/exec"
10+)
11+
12+// shellCommand runs line with `sh -c`.
13+func shellCommand(ctx context.Context, line string) *exec.Cmd {
14+ return exec.CommandContext(ctx, "sh", "-c", line)
15+}
@@ -0,0 +1,15 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build !windows
4+
5+package app
6+
7+import (
8+ "context"
9+ "os/exec"
10+)
11+
12+// shellCommand runs line with `sh -c`.
13+func shellCommand(ctx context.Context, line string) *exec.Cmd {
14+ return exec.CommandContext(ctx, "sh", "-c", line)
15+}
A internal/app/cli_shell_windows.go +19 -0
@@ -0,0 +1,19 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build windows
4+
5+package app
6+
7+import (
8+ "context"
9+ "os/exec"
10+ "syscall"
11+)
12+
13+// shellCommand runs line with `cmd /C`. The command line is passed as is:
14+// Go's argument quoting would mangle quotes cmd.exe means to read itself.
15+func shellCommand(ctx context.Context, line string) *exec.Cmd {
16+ c := exec.CommandContext(ctx, "cmd")
17+ c.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd /C " + line}
18+ return c
19+}
@@ -0,0 +1,19 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build windows
4+
5+package app
6+
7+import (
8+ "context"
9+ "os/exec"
10+ "syscall"
11+)
12+
13+// shellCommand runs line with `cmd /C`. The command line is passed as is:
14+// Go's argument quoting would mangle quotes cmd.exe means to read itself.
15+func shellCommand(ctx context.Context, line string) *exec.Cmd {
16+ c := exec.CommandContext(ctx, "cmd")
17+ c.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd /C " + line}
18+ return c
19+}
M internal/app/cli_version.go +13 -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,10 @@ 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+ return err
54+ }
5055 if url == "" {
5156 if *asJSON {
5257 return writeJSON(out, jsonVersion{Client: jsonClientVersion{Version: env.version}})
@@ -55,7 +60,8 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
5560 return err
5661 }
5762
58- c, err := env.client(cmd, true)
63+ // GetVersion is anonymous: token.cmd is not run for it.
64+ c, err := env.clientNoTokenCmd(cmd, true)
5965 if err != nil {
6066 return err
6167 }
@@ -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,10 @@ 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+ return err
54+ }
50 if url == "" {55 if url == "" {
51 if *asJSON {56 if *asJSON {
52 return writeJSON(out, jsonVersion{Client: jsonClientVersion{Version: env.version}})57 return writeJSON(out, jsonVersion{Client: jsonClientVersion{Version: env.version}})
@@ -55,7 +60,8 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
55 return err60 return err
56 }61 }
57 62
58- c, err := env.client(cmd, true)63+ // GetVersion is anonymous: token.cmd is not run for it.
64+ c, err := env.clientNoTokenCmd(cmd, true)
59 if err != nil {65 if err != nil {
60 return err66 return err
61 }67 }

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