Skip to content

Commit 6a69aba

Browse files
committed
feat(cli): shell completion, richer help, safer and scriptable commands
1 parent 22deb6a commit 6a69aba

1,148 files changed

Lines changed: 38567 additions & 5866 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.goreleaser.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ before:
77
- sh -c 'go run . completion bash > completions/hostinger.bash'
88
- sh -c 'go run . completion zsh > completions/hostinger.zsh'
99
- sh -c 'go run . completion fish > completions/hostinger.fish'
10+
- go run ./tools/gendocs -man manpages
1011

1112
builds:
1213
- main: ./main.go
@@ -30,6 +31,7 @@ archives:
3031
- name_template: "hostinger-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
3132
files:
3233
- completions/*
34+
- manpages/*
3335
wrap_in_directory: false
3436

3537
source:
@@ -65,6 +67,7 @@ brews:
6567
bash_completion.install "completions/hostinger.bash" => "hostinger"
6668
zsh_completion.install "completions/hostinger.zsh" => "_hostinger"
6769
fish_completion.install "completions/hostinger.fish"
70+
man1.install Dir["manpages/*.1"]
6871
6972
changelog:
7073
disable: false

‎AUTOCOMPLETE.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,22 @@ Auto-completion can be generated for multiple shells. The currently supported sh
1010
- fish
1111
- PowerShell
1212

13+
Completion covers commands, flag values with a fixed set of choices (`--format`, enum flags) and IDs:
14+
where a command takes the ID or name of an existing resource, `<TAB>` lists yours (with a name or
15+
hostname next to each), fetched from the API with your configured credentials. Results are cached for
16+
two minutes, and completion never opens a browser sign-in.
17+
18+
## Quick setup
19+
20+
```bash
21+
hostinger completion install # detects the shell from $SHELL
22+
hostinger completion install zsh # or name it: bash, zsh, fish, powershell
23+
```
24+
25+
It writes the script where the shell loads it from and prints any one-time setup step still needed.
26+
Run it again after upgrading to pick up new commands. Homebrew installs already include completions.
27+
The sections below set it up by hand instead.
28+
1329
After adding shell auto-completion, remember to refresh your shell profile by logging out from the shell and log back in.
1430

1531
## Bash

‎README.md‎

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,36 @@
1616
brew install hostinger/tap/hostinger
1717
```
1818

19-
Upgrade with `brew upgrade hostinger`. Shell tab-completion (bash/zsh/fish) is installed
20-
automatically.
19+
Upgrade with `brew upgrade hostinger`. Shell tab-completion (bash/zsh/fish) and man pages
20+
(`man hostinger`) are installed automatically.
2121

2222
### Binary download
2323

2424
Download the binary for your platform from the
25-
[releases page](https://github.com/hostinger/api-cli/releases).
25+
[releases page](https://github.com/hostinger/api-cli/releases). Then enable tab-completion
26+
with `hostinger completion install` (see [AUTOCOMPLETE.md](AUTOCOMPLETE.md)).
2627

2728
## Configuration
2829

29-
Create `$HOME/.hostinger.yaml`:
30+
Sign in through the browser:
31+
32+
```sh
33+
hostinger auth login
34+
```
35+
36+
or use an API token instead: run `hostinger config set api_token` (it asks for the token
37+
without echoing it) or set the `HOSTINGER_API_TOKEN` environment variable. A token takes precedence over
38+
the sign-in. Generate one at [hPanel → API](https://hpanel.hostinger.com/profile/api).
39+
40+
`hostinger auth status` shows which credentials are in use. Settings live in
41+
`$HOME/.hostinger.yaml` (`hostinger config list` shows them):
3042

3143
```yaml
3244
api_token: <your API token>
45+
format: json # default output format
46+
no_input: false # true: never prompt
3347
```
3448
35-
or set the `HOSTINGER_API_TOKEN` environment variable. Generate a token at
36-
[hPanel → API](https://hpanel.hostinger.com/profile/api).
37-
3849
## Usage
3950
4051
```
@@ -50,7 +61,36 @@ hostinger vps vm start 123 # vm is an alias for virtual-machines
5061
hostinger dns records list example.com
5162
```
5263

53-
Output format defaults to a table; use `--format json|table|tree` to change it.
54-
Command documentation lives in [docs/](docs/), and `manifest.json` maps every
55-
API operation to its command. Shell autocompletion: see
56-
[AUTOCOMPLETE.md](AUTOCOMPLETE.md).
64+
Every command's `--help` lists its required flags separately and shows examples. Flags that take
65+
JSON accept inline JSON, `@file.json`, or `-` for stdin; the help shows the expected shape.
66+
67+
### Output
68+
69+
- Tables on a terminal, JSON when the output is piped or redirected. Choose explicitly with
70+
`-o/--format table|json|yaml|tree`, or set a default with `hostinger config set format json`.
71+
- `--jq '<expr>'` filters the JSON response (strings print raw), e.g. `--jq '.[].id'`.
72+
- List commands with pages take `--all` to fetch every page.
73+
74+
### Prompts and safety
75+
76+
- In a terminal, missing arguments and required flags are asked for, with choices listed where
77+
they are known. `--no-input` (or `no_input: true`) turns prompting off.
78+
- Commands that delete, stop, restore or purchase ask for confirmation. Pass `--yes` to skip it;
79+
non-interactive runs (scripts, CI) must pass `--yes`.
80+
- `--dry-run` prints the request as a `curl` command instead of sending it.
81+
- Commands that start an asynchronous action take `--wait` to block until it finishes.
82+
- `--verbose` prints each HTTP request and its status to stderr.
83+
84+
### Exit codes
85+
86+
| Code | Meaning |
87+
|------|-----------------------------------------------|
88+
| 0 | Success |
89+
| 1 | API, network or other error; aborted prompt |
90+
| 2 | Invalid arguments, flags or input |
91+
| 3 | No credentials, or authentication failed (HTTP 401/403) |
92+
| 4 | Not found (HTTP 404) |
93+
| 5 | Validation failed (HTTP 422) |
94+
95+
Command documentation lives in [docs/](docs/) and in the man pages, and `manifest.json` maps every
96+
API operation to its command. Shell autocompletion: see [AUTOCOMPLETE.md](AUTOCOMPLETE.md).

‎api/oauth.go‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,54 @@ func (s *OAuthService) Token(ctx context.Context) (string, error) {
138138
return s.resolve(ctx)
139139
}
140140

141+
// ErrNotSignedIn is returned by TokenNoLogin when only an interactive sign-in
142+
// could produce a token.
143+
var ErrNotSignedIn = errors.New("not signed in")
144+
145+
// TokenNoLogin is Token without the interactive fallback: the cached token, or
146+
// a refresh, or ErrNotSignedIn. Shell completion uses it so a TAB press never
147+
// opens a browser.
148+
func (s *OAuthService) TokenNoLogin(ctx context.Context) (string, error) {
149+
s.mu.Lock()
150+
defer s.mu.Unlock()
151+
152+
creds, _ := s.loadCredentials()
153+
if creds.AccessToken != "" && creds.ExpiresAt != 0 && nowMillis() < creds.ExpiresAt {
154+
return creds.AccessToken, nil
155+
}
156+
if creds.RefreshToken == "" || creds.ClientID == "" {
157+
return "", ErrNotSignedIn
158+
}
159+
return s.refresh(ctx, creds)
160+
}
161+
162+
// Status describes the stored sign-in without touching the network.
163+
type Status struct {
164+
SignedIn bool // an access or refresh token is stored
165+
Expired bool // the access token has expired (a refresh may still work)
166+
CanRefresh bool // a refresh token is stored
167+
ExpiresAt time.Time // access token expiry; zero when unknown
168+
CredPath string
169+
}
170+
171+
// Status reports the stored sign-in state.
172+
func (s *OAuthService) Status() Status {
173+
s.mu.Lock()
174+
defer s.mu.Unlock()
175+
176+
creds, _ := s.loadCredentials()
177+
st := Status{
178+
SignedIn: creds.AccessToken != "" || creds.RefreshToken != "",
179+
CanRefresh: creds.RefreshToken != "" && creds.ClientID != "",
180+
CredPath: s.credPath,
181+
}
182+
if creds.ExpiresAt != 0 {
183+
st.ExpiresAt = time.UnixMilli(creds.ExpiresAt)
184+
st.Expired = nowMillis() >= creds.ExpiresAt
185+
}
186+
return st
187+
}
188+
141189
// Login runs the full interactive PKCE flow regardless of cache state.
142190
func (s *OAuthService) Login(ctx context.Context) (string, error) {
143191
s.mu.Lock()

‎api/request.go‎

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,20 @@ import (
1111
"net/http"
1212
)
1313

14+
// Request returns the API client for a command run. Without a static token it
15+
// signs in through OAuth, opening the browser when needed.
1416
func Request() client.ClientWithResponsesInterface {
17+
return newClient(true)
18+
}
19+
20+
// CompletionRequest returns an API client for shell completion: it uses the
21+
// static token or an already stored OAuth sign-in, and never starts an
22+
// interactive login.
23+
func CompletionRequest() client.ClientWithResponsesInterface {
24+
return newClient(false)
25+
}
26+
27+
func newClient(interactive bool) client.ClientWithResponsesInterface {
1528
apiURL := viper.GetString("api_url")
1629
if apiURL == "" {
1730
apiURL = "https://developers.hostinger.com"
@@ -21,14 +34,22 @@ func Request() client.ClientWithResponsesInterface {
2134
client.WithRequestEditorFn(addUserAgent),
2235
}
2336

24-
if staticToken := viper.GetString("api_token"); staticToken != "" {
37+
var doer client.HttpRequestDoer = &http.Client{}
38+
if viper.GetBool("verbose") {
39+
doer = &verboseDoer{base: doer}
40+
}
41+
42+
if interactive && viper.GetBool("dry_run") {
43+
// Print the request instead of sending it; no credentials are needed.
44+
opts = append(opts, client.WithHTTPClient(&dryRunDoer{}))
45+
} else if staticToken := viper.GetString("api_token"); staticToken != "" {
2546
// Static token wins: use it unchanged. OAuth and 401 re-auth are skipped
2647
// because there is no refresh source for a user-managed token.
2748
bearer, err := securityprovider.NewSecurityProviderBearerToken(staticToken)
2849
if err != nil {
2950
panic(err)
3051
}
31-
opts = append(opts, client.WithRequestEditorFn(bearer.Intercept))
52+
opts = append(opts, client.WithRequestEditorFn(bearer.Intercept), client.WithHTTPClient(doer))
3253
} else {
3354
// No static token: obtain the bearer via the OAuth service, and retry
3455
// once on a 401 after forcing a fresh token.
@@ -37,8 +58,8 @@ func Request() client.ClientWithResponsesInterface {
3758
log.Fatal(err)
3859
}
3960
opts = append(opts,
40-
client.WithRequestEditorFn(oauthBearer(oauth)),
41-
client.WithHTTPClient(&oauthRetryDoer{oauth: oauth, base: &http.Client{}}),
61+
client.WithRequestEditorFn(oauthBearer(oauth, interactive)),
62+
client.WithHTTPClient(&oauthRetryDoer{oauth: oauth, base: doer, interactive: interactive}),
4263
)
4364
}
4465

@@ -51,10 +72,15 @@ func Request() client.ClientWithResponsesInterface {
5172
}
5273

5374
// oauthBearer resolves a token (cached -> refresh -> interactive login) and
54-
// attaches it as the Authorization header before each request is sent.
55-
func oauthBearer(oauth *OAuthService) client.RequestEditorFn {
75+
// attaches it as the Authorization header before each request is sent. When
76+
// interactive is false the login step is skipped.
77+
func oauthBearer(oauth *OAuthService, interactive bool) client.RequestEditorFn {
5678
return func(ctx context.Context, req *http.Request) error {
57-
token, err := oauth.Token(ctx)
79+
resolve := oauth.Token
80+
if !interactive {
81+
resolve = oauth.TokenNoLogin
82+
}
83+
token, err := resolve(ctx)
5884
if err != nil {
5985
return err
6086
}
@@ -65,15 +91,17 @@ func oauthBearer(oauth *OAuthService) client.RequestEditorFn {
6591

6692
// oauthRetryDoer performs API requests and, on a 401, forces a fresh token via
6793
// Reauthenticate and retries the request exactly once (spec §8). A second 401
68-
// is returned to the caller unchanged — it never loops.
94+
// is returned to the caller unchanged — it never loops. Non-interactive
95+
// clients return the first 401 as is.
6996
type oauthRetryDoer struct {
70-
oauth *OAuthService
71-
base client.HttpRequestDoer
97+
oauth *OAuthService
98+
base client.HttpRequestDoer
99+
interactive bool
72100
}
73101

74102
func (d *oauthRetryDoer) Do(req *http.Request) (*http.Response, error) {
75103
resp, err := d.base.Do(req)
76-
if err != nil || resp.StatusCode != http.StatusUnauthorized {
104+
if err != nil || resp.StatusCode != http.StatusUnauthorized || !d.interactive {
77105
return resp, err
78106
}
79107

‎api/transport.go‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package api
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net/http"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"time"
11+
12+
"github.com/hostinger/api-cli/client"
13+
"github.com/hostinger/api-cli/utils"
14+
)
15+
16+
// Streams for --dry-run (the command's output) and --verbose (diagnostics);
17+
// package vars for tests. nil means os.Stdout / os.Stderr at write time.
18+
var (
19+
dryRunOut io.Writer
20+
verboseOut io.Writer
21+
)
22+
23+
func writerOr(w, fallback io.Writer) io.Writer {
24+
if w != nil {
25+
return w
26+
}
27+
return fallback
28+
}
29+
30+
// dryRunDoer prints each request as an equivalent curl command and returns
31+
// utils.ErrDryRun instead of sending it. The token is written as an
32+
// environment variable reference, never its value.
33+
type dryRunDoer struct{}
34+
35+
func (dryRunDoer) Do(req *http.Request) (*http.Response, error) {
36+
var b strings.Builder
37+
fmt.Fprintf(&b, "curl -X %s %s", req.Method, shellQuote(req.URL.String()))
38+
fmt.Fprintf(&b, " \\\n -H \"Authorization: Bearer $%sAPI_TOKEN\"", utils.EnvPrefix)
39+
if ct := req.Header.Get("Content-Type"); ct != "" {
40+
fmt.Fprintf(&b, " \\\n -H %s", shellQuote("Content-Type: "+ct))
41+
}
42+
if req.Body != nil && req.Body != http.NoBody {
43+
body, err := io.ReadAll(req.Body)
44+
if err != nil {
45+
return nil, err
46+
}
47+
if len(body) > 0 {
48+
fmt.Fprintf(&b, " \\\n -d %s", shellQuote(string(body)))
49+
}
50+
}
51+
fmt.Fprintln(writerOr(dryRunOut, os.Stdout), b.String())
52+
return nil, utils.ErrDryRun
53+
}
54+
55+
// noTokenDoer fails every request of a build without OAuth when no API token
56+
// is configured.
57+
type noTokenDoer struct{}
58+
59+
func (noTokenDoer) Do(*http.Request) (*http.Response, error) {
60+
return nil, fmt.Errorf("%w: set %sAPI_TOKEN or run '%s config set api_token'",
61+
utils.ErrNoCredentials, utils.EnvPrefix, filepath.Base(os.Args[0]))
62+
}
63+
64+
// verboseDoer logs each request line and the response status with timing to
65+
// stderr. Headers are not logged, so credentials never reach the log.
66+
type verboseDoer struct {
67+
base client.HttpRequestDoer
68+
}
69+
70+
func (d *verboseDoer) Do(req *http.Request) (*http.Response, error) {
71+
fmt.Fprintf(writerOr(verboseOut, os.Stderr), "> %s %s\n", req.Method, req.URL.String())
72+
start := time.Now()
73+
resp, err := d.base.Do(req)
74+
elapsed := time.Since(start).Round(time.Millisecond)
75+
if err != nil {
76+
fmt.Fprintf(writerOr(verboseOut, os.Stderr), "< error after %s: %v\n", elapsed, err)
77+
return resp, err
78+
}
79+
fmt.Fprintf(writerOr(verboseOut, os.Stderr), "< %s (%s)\n", resp.Status, elapsed)
80+
return resp, nil
81+
}
82+
83+
func shellQuote(s string) string {
84+
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
85+
}

0 commit comments

Comments
 (0)