fix(cli): run token.cmd only for the server of the config file
A token fetched for the url of the file must not be sent to another server named by ERMITE_URL. When ERMITE_URL differs, the call is anonymous and a notice says to set ERMITE_TOKEN.
- commit
- fff33377274686d95de8d2758f832b168febc8c4
- author
- brice · · 2026-10-12 15:07 UTC
- parent
- 6f11e7d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +66 -8
M README.md +1 -1
| @@ -55,7 +55,7 @@ ermite config set token.cmd 'pass show ermite/token' # or: gopass show -o ermi | |||
| 55 | 55 | ermite config show # path and stored values; unset removes a key | |
| 56 | 56 | ``` | |
| 57 | 57 | ||
| 58 | - | `token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and terminal (so a pinentry or biometric prompt works). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file is writable by other users. A failure is reported in one line without its output. A token sent over plain `http://` to a host that is not loopback triggers a warning on every call. | |
| 58 | + | `token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and terminal (so a pinentry or biometric prompt works). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file is writable by other users. It belongs to the server of the file: when `ERMITE_URL` names a different server than the file's `url`, it is not run and the call is anonymous (with a notice; set `ERMITE_TOKEN` to authenticate there). A failure is reported in one line without its output. A token sent over plain `http://` to a host that is not loopback triggers a warning on every call. | |
| 59 | 59 | ||
| 60 | 60 | ```sh | |
| 61 | 61 | export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_... | |
| @@ -55,7 +55,7 @@ ermite config set token.cmd 'pass show ermite/token' # or: gopass show -o ermi | |||||
| 55 | ermite config show # path and stored values; unset removes a key | 55 | ermite config show # path and stored values; unset removes a key | ||
| 56 | ``` | 56 | ``` | ||
| 57 | 57 | ||||
| 58 | - | `token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and terminal (so a pinentry or biometric prompt works). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file is writable by other users. A failure is reported in one line without its output. A token sent over plain `http://` to a host that is not loopback triggers a warning on every call. | 58 | + | `token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and terminal (so a pinentry or biometric prompt works). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file is writable by other users. It belongs to the server of the file: when `ERMITE_URL` names a different server than the file's `url`, it is not run and the call is anonymous (with a notice; set `ERMITE_TOKEN` to authenticate there). A failure is reported in one line without its output. A token sent over plain `http://` to a host that is not loopback triggers a warning on every call. |
| 59 | 59 | ||||
| 60 | ```sh | 60 | ```sh | ||
| 61 | export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_... | 61 | export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_... | ||
M internal/app/cli_config.go +3 -2
| @@ -32,8 +32,9 @@ func newConfigCmd(env *cliEnv) *cobra.Command { | |||
| 32 | 32 | "token.cmd is a command line run with the system shell when a command needs the token (at most\n" + | |
| 33 | 33 | "once per run): it must print the token alone on one line. Use any secret manager (pass, gopass,\n" + | |
| 34 | 34 | "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" + | |
| 35 | - | "take precedence over the file; `ermite version` and `ermite config` never run token.cmd. The\n" + | |
| 36 | - | "command is not run when the file is writable by other users.", | |
| 35 | + | "take precedence over the file. token.cmd belongs to the server of the file: it is not run when\n" + | |
| 36 | + | "ERMITE_URL names another server than the url of the file (the call is then anonymous, with a\n" + | |
| 37 | + | "notice: set ERMITE_TOKEN). `ermite version` and `ermite config` never run it.", | |
| 37 | 38 | } | |
| 38 | 39 | cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env)) | |
| 39 | 40 | return cmd | |
| @@ -32,8 +32,9 @@ func newConfigCmd(env *cliEnv) *cobra.Command { | |||||
| 32 | "token.cmd is a command line run with the system shell when a command needs the token (at most\n" + | 32 | "token.cmd is a command line run with the system shell when a command needs the token (at most\n" + | ||
| 33 | "once per run): it must print the token alone on one line. Use any secret manager (pass, gopass,\n" + | 33 | "once per run): it must print the token alone on one line. Use any secret manager (pass, gopass,\n" + | ||
| 34 | "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" + | 34 | "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" + | ||
| 35 | - | "take precedence over the file; `ermite version` and `ermite config` never run token.cmd. The\n" + | 35 | + | "take precedence over the file. token.cmd belongs to the server of the file: it is not run when\n" + |
| 36 | - | "command is not run when the file is writable by other users.", | 36 | + | "ERMITE_URL names another server than the url of the file (the call is then anonymous, with a\n" + |
| 37 | + | "notice: set ERMITE_TOKEN). `ermite version` and `ermite config` never run it.", | |||
| 37 | } | 38 | } | ||
| 38 | cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env)) | 39 | cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env)) | ||
| 39 | return cmd | 40 | return cmd | ||
M internal/app/cli_configfile.go +28 -2
| @@ -107,7 +107,10 @@ func (e *cliEnv) resolveToken(cmd *cobra.Command, warn, runCmd bool) (string, er | |||
| 107 | 107 | return e.tokenFromCmd(cmd, warn) | |
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | - | // tokenFromCmd is the output of token.cmd, or "" when the file sets none. | |
| 110 | + | // tokenFromCmd is the output of token.cmd, or "" when the file sets none or | |
| 111 | + | // when ERMITE_URL points somewhere else than the url of the file: the token | |
| 112 | + | // the command fetches belongs to the server of the file, and must not be sent | |
| 113 | + | // to another one. | |
| 111 | 114 | func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) { | |
| 112 | 115 | f, path, err := e.readFile(cmd, warn) | |
| 113 | 116 | if err != nil { | |
| @@ -117,10 +120,33 @@ func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) { | |||
| 117 | 120 | if line == "" { | |
| 118 | 121 | return "", nil | |
| 119 | 122 | } | |
| 120 | - | e.tokenOnce.Do(func() { e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), path, line) }) | |
| 123 | + | e.tokenOnce.Do(func() { | |
| 124 | + | if !e.urlIsFromFile(f) { | |
| 125 | + | if warn { | |
| 126 | + | noticef(cmd, "%s differs from the config file url: token.cmd not run (set %s)", client.EnvURL, client.EnvToken) | |
| 127 | + | } | |
| 128 | + | return | |
| 129 | + | } | |
| 130 | + | e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), path, line) | |
| 131 | + | }) | |
| 121 | 132 | return e.token, e.tokenErr | |
| 122 | 133 | } | |
| 123 | 134 | ||
| 135 | + | // urlIsFromFile reports whether the server in use is the one of the config | |
| 136 | + | // file: ERMITE_URL is unset, or names the same address as the file's url. | |
| 137 | + | func (e *cliEnv) urlIsFromFile(f *cliFile) bool { | |
| 138 | + | envURL := strings.TrimSpace(e.getenv(client.EnvURL)) | |
| 139 | + | if envURL == "" { | |
| 140 | + | return true | |
| 141 | + | } | |
| 142 | + | a, err := client.ParseURL(envURL) | |
| 143 | + | if err != nil { | |
| 144 | + | return false | |
| 145 | + | } | |
| 146 | + | b, err := client.ParseURL(strings.TrimSpace(f.URL)) | |
| 147 | + | return err == nil && a.String() == b.String() | |
| 148 | + | } | |
| 149 | + | ||
| 124 | 150 | // fetchToken runs token.cmd. Neither its output nor its error text reach the | |
| 125 | 151 | // caller's messages: the output is the secret. | |
| 126 | 152 | func (e *cliEnv) fetchToken(ctx context.Context, path, line string) (string, error) { | |
| @@ -107,7 +107,10 @@ func (e *cliEnv) resolveToken(cmd *cobra.Command, warn, runCmd bool) (string, er | |||||
| 107 | return e.tokenFromCmd(cmd, warn) | 107 | return e.tokenFromCmd(cmd, warn) | ||
| 108 | } | 108 | } | ||
| 109 | 109 | ||||
| 110 | - | // tokenFromCmd is the output of token.cmd, or "" when the file sets none. | 110 | + | // tokenFromCmd is the output of token.cmd, or "" when the file sets none or |
| 111 | + | // when ERMITE_URL points somewhere else than the url of the file: the token | |||
| 112 | + | // the command fetches belongs to the server of the file, and must not be sent | |||
| 113 | + | // to another one. | |||
| 111 | func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) { | 114 | func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) { | ||
| 112 | f, path, err := e.readFile(cmd, warn) | 115 | f, path, err := e.readFile(cmd, warn) | ||
| 113 | if err != nil { | 116 | if err != nil { | ||
| @@ -117,10 +120,33 @@ func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) { | |||||
| 117 | if line == "" { | 120 | if line == "" { | ||
| 118 | return "", nil | 121 | return "", nil | ||
| 119 | } | 122 | } | ||
| 120 | - | e.tokenOnce.Do(func() { e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), path, line) }) | 123 | + | e.tokenOnce.Do(func() { |
| 124 | + | if !e.urlIsFromFile(f) { | |||
| 125 | + | if warn { | |||
| 126 | + | noticef(cmd, "%s differs from the config file url: token.cmd not run (set %s)", client.EnvURL, client.EnvToken) | |||
| 127 | + | } | |||
| 128 | + | return | |||
| 129 | + | } | |||
| 130 | + | e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), path, line) | |||
| 131 | + | }) | |||
| 121 | return e.token, e.tokenErr | 132 | return e.token, e.tokenErr | ||
| 122 | } | 133 | } | ||
| 123 | 134 | ||||
| 135 | + | // urlIsFromFile reports whether the server in use is the one of the config | |||
| 136 | + | // file: ERMITE_URL is unset, or names the same address as the file's url. | |||
| 137 | + | func (e *cliEnv) urlIsFromFile(f *cliFile) bool { | |||
| 138 | + | envURL := strings.TrimSpace(e.getenv(client.EnvURL)) | |||
| 139 | + | if envURL == "" { | |||
| 140 | + | return true | |||
| 141 | + | } | |||
| 142 | + | a, err := client.ParseURL(envURL) | |||
| 143 | + | if err != nil { | |||
| 144 | + | return false | |||
| 145 | + | } | |||
| 146 | + | b, err := client.ParseURL(strings.TrimSpace(f.URL)) | |||
| 147 | + | return err == nil && a.String() == b.String() | |||
| 148 | + | } | |||
| 149 | + | ||||
| 124 | // fetchToken runs token.cmd. Neither its output nor its error text reach the | 150 | // fetchToken runs token.cmd. Neither its output nor its error text reach the | ||
| 125 | // caller's messages: the output is the secret. | 151 | // caller's messages: the output is the secret. | ||
| 126 | func (e *cliEnv) fetchToken(ctx context.Context, path, line string) (string, error) { | 152 | func (e *cliEnv) fetchToken(ctx context.Context, path, line string) (string, error) { | ||
M internal/app/cli_configfile_test.go +33 -2
| @@ -85,15 +85,46 @@ func TestEnvironmentWinsOverConfigFile(t *testing.T) { | |||
| 85 | 85 | assert.Empty(t, calls, "the url of the file is not used when ERMITE_URL is set") | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | - | func TestEnvURLAloneStillUsesTokenCmd(t *testing.T) { | |
| 88 | + | func TestEnvURLDifferentFromFileKeepsTokenCmdOut(t *testing.T) { | |
| 89 | 89 | f := listFake() | |
| 90 | 90 | env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t)}) | |
| 91 | 91 | writeConfigFile(t, env, "url = http://file.invalid\ntoken.cmd = get-token\n", 0o600) | |
| 92 | 92 | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 93 | 93 | ||
| 94 | - | runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 94 | + | res := runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 95 | + | assert.Empty(t, *ran, "the token of the file's server must not go to another server") | |
| 96 | + | assert.Empty(t, lastAuth(f)) | |
| 97 | + | assert.Equal(t, 1, strings.Count(res.stderr, "ermite: ERMITE_URL differs from the config file url: token.cmd not run (set ERMITE_TOKEN)\n"), res.stderr) | |
| 98 | + | ||
| 99 | + | t.Run("file without url", func(t *testing.T) { | |
| 100 | + | env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t)}) | |
| 101 | + | writeConfigFile(t, env, "token.cmd = get-token\n", 0o600) | |
| 102 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 103 | + | res := runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 104 | + | assert.Empty(t, *ran) | |
| 105 | + | assert.Contains(t, res.stderr, "token.cmd not run") | |
| 106 | + | }) | |
| 107 | + | t.Run("invalid ERMITE_URL", func(t *testing.T) { | |
| 108 | + | env := testEnv(t, map[string]string{"ERMITE_URL": "nope"}) | |
| 109 | + | writeConfigFile(t, env, "url = http://file.invalid\ntoken.cmd = get-token\n", 0o600) | |
| 110 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 111 | + | require.Error(t, runCLIWith(t, env, "", "repo", "list").err) | |
| 112 | + | assert.Empty(t, *ran) | |
| 113 | + | }) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestEnvURLSameAsFileUsesTokenCmd(t *testing.T) { | |
| 117 | + | f := listFake() | |
| 118 | + | url := f.serve(t) | |
| 119 | + | // the same address, written differently | |
| 120 | + | env := testEnv(t, map[string]string{"ERMITE_URL": " " + strings.ToUpper(url[:4]) + url[4:] + "/ "}) | |
| 121 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |
| 122 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |
| 123 | + | ||
| 124 | + | res := runCLIWith(t, env, "", "repo", "list").ok(t) | |
| 95 | 125 | assert.Equal(t, "Bearer ermite_cmd", lastAuth(f)) | |
| 96 | 126 | assert.Len(t, *ran, 1) | |
| 127 | + | assert.NotContains(t, res.stderr, "token.cmd") | |
| 97 | 128 | } | |
| 98 | 129 | ||
| 99 | 130 | func TestTokenCmdNotRunWhenEnvTokenSet(t *testing.T) { | |
| @@ -85,15 +85,46 @@ func TestEnvironmentWinsOverConfigFile(t *testing.T) { | |||||
| 85 | assert.Empty(t, calls, "the url of the file is not used when ERMITE_URL is set") | 85 | assert.Empty(t, calls, "the url of the file is not used when ERMITE_URL is set") | ||
| 86 | } | 86 | } | ||
| 87 | 87 | ||||
| 88 | - | func TestEnvURLAloneStillUsesTokenCmd(t *testing.T) { | 88 | + | func TestEnvURLDifferentFromFileKeepsTokenCmdOut(t *testing.T) { |
| 89 | f := listFake() | 89 | f := listFake() | ||
| 90 | env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t)}) | 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) | 91 | writeConfigFile(t, env, "url = http://file.invalid\ntoken.cmd = get-token\n", 0o600) | ||
| 92 | ran := tokenCmdStub(env, "ermite_cmd", nil) | 92 | ran := tokenCmdStub(env, "ermite_cmd", nil) | ||
| 93 | 93 | ||||
| 94 | - | runCLIWith(t, env, "", "repo", "list").ok(t) | 94 | + | res := runCLIWith(t, env, "", "repo", "list").ok(t) |
| 95 | + | assert.Empty(t, *ran, "the token of the file's server must not go to another server") | |||
| 96 | + | assert.Empty(t, lastAuth(f)) | |||
| 97 | + | assert.Equal(t, 1, strings.Count(res.stderr, "ermite: ERMITE_URL differs from the config file url: token.cmd not run (set ERMITE_TOKEN)\n"), res.stderr) | |||
| 98 | + | ||||
| 99 | + | t.Run("file without url", func(t *testing.T) { | |||
| 100 | + | env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t)}) | |||
| 101 | + | writeConfigFile(t, env, "token.cmd = get-token\n", 0o600) | |||
| 102 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |||
| 103 | + | res := runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 104 | + | assert.Empty(t, *ran) | |||
| 105 | + | assert.Contains(t, res.stderr, "token.cmd not run") | |||
| 106 | + | }) | |||
| 107 | + | t.Run("invalid ERMITE_URL", func(t *testing.T) { | |||
| 108 | + | env := testEnv(t, map[string]string{"ERMITE_URL": "nope"}) | |||
| 109 | + | writeConfigFile(t, env, "url = http://file.invalid\ntoken.cmd = get-token\n", 0o600) | |||
| 110 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |||
| 111 | + | require.Error(t, runCLIWith(t, env, "", "repo", "list").err) | |||
| 112 | + | assert.Empty(t, *ran) | |||
| 113 | + | }) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestEnvURLSameAsFileUsesTokenCmd(t *testing.T) { | |||
| 117 | + | f := listFake() | |||
| 118 | + | url := f.serve(t) | |||
| 119 | + | // the same address, written differently | |||
| 120 | + | env := testEnv(t, map[string]string{"ERMITE_URL": " " + strings.ToUpper(url[:4]) + url[4:] + "/ "}) | |||
| 121 | + | writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600) | |||
| 122 | + | ran := tokenCmdStub(env, "ermite_cmd", nil) | |||
| 123 | + | ||||
| 124 | + | res := runCLIWith(t, env, "", "repo", "list").ok(t) | |||
| 95 | assert.Equal(t, "Bearer ermite_cmd", lastAuth(f)) | 125 | assert.Equal(t, "Bearer ermite_cmd", lastAuth(f)) | ||
| 96 | assert.Len(t, *ran, 1) | 126 | assert.Len(t, *ran, 1) | ||
| 127 | + | assert.NotContains(t, res.stderr, "token.cmd") | |||
| 97 | } | 128 | } | ||
| 98 | 129 | ||||
| 99 | func TestTokenCmdNotRunWhenEnvTokenSet(t *testing.T) { | 130 | func TestTokenCmdNotRunWhenEnvTokenSet(t *testing.T) { | ||
M skills/ermite/CLI.md +1 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # ermite CLI reference | |
| 2 | 2 | ||
| 3 | - | Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads); they fall back to the `url` and `token.cmd` of the config file (`~/.config/ermite/config`, or the file named by `ERMITE_CONFIG`), the environment first. Agents: use `ERMITE_TOKEN`, or your own `ERMITE_CONFIG` file; do not edit the owner's `~/.config/ermite/config` and never run `token.cmd` yourself or print what it prints. Run `ermite <command> --help` for the authoritative text of a command and its JSON fields. | |
| 3 | + | Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads); they fall back to the `url` and `token.cmd` of the config file (`~/.config/ermite/config`, or the file named by `ERMITE_CONFIG`), the environment first (`token.cmd` is only run for the server of the file: with a different `ERMITE_URL` set `ERMITE_TOKEN` too). Agents: use `ERMITE_TOKEN`, or your own `ERMITE_CONFIG` file; do not edit the owner's `~/.config/ermite/config` and never run `token.cmd` yourself or print what it prints. Run `ermite <command> --help` for the authoritative text of a command and its JSON fields. | |
| 4 | 4 | ||
| 5 | 5 | Errors are one line on standard error (`ermite: ...`) with exit status 1. `--json` prints stable field names. `ermite completion bash|zsh|fish` completes repository names from the server. | |
| 6 | 6 | ||
| @@ -1,6 +1,6 @@ | |||||
| 1 | # ermite CLI reference | 1 | # ermite CLI reference | ||
| 2 | 2 | ||||
| 3 | - | Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads); they fall back to the `url` and `token.cmd` of the config file (`~/.config/ermite/config`, or the file named by `ERMITE_CONFIG`), the environment first. Agents: use `ERMITE_TOKEN`, or your own `ERMITE_CONFIG` file; do not edit the owner's `~/.config/ermite/config` and never run `token.cmd` yourself or print what it prints. Run `ermite <command> --help` for the authoritative text of a command and its JSON fields. | 3 | + | Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads); they fall back to the `url` and `token.cmd` of the config file (`~/.config/ermite/config`, or the file named by `ERMITE_CONFIG`), the environment first (`token.cmd` is only run for the server of the file: with a different `ERMITE_URL` set `ERMITE_TOKEN` too). Agents: use `ERMITE_TOKEN`, or your own `ERMITE_CONFIG` file; do not edit the owner's `~/.config/ermite/config` and never run `token.cmd` yourself or print what it prints. Run `ermite <command> --help` for the authoritative text of a command and its JSON fields. |
| 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 | ||||