elva-cli 0.3.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- elva_cli-0.5.0/CHANGELOG.md +32 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/PKG-INFO +119 -3
- {elva_cli-0.3.0 → elva_cli-0.5.0}/README.md +115 -2
- {elva_cli-0.3.0 → elva_cli-0.5.0}/pyproject.toml +6 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/_version.py +2 -2
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/auth/__init__.py +22 -3
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/auth/session.py +125 -17
- elva_cli-0.5.0/src/elva_cli/commands/import_.py +94 -0
- elva_cli-0.5.0/src/elva_cli/commands/whoami.py +18 -0
- elva_cli-0.5.0/src/elva_cli/core/api/http.py +254 -0
- elva_cli-0.5.0/src/elva_cli/core/api/identity.py +21 -0
- elva_cli-0.5.0/src/elva_cli/core/api/targets.py +150 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/core/services/auth.py +2 -1
- elva_cli-0.5.0/src/elva_cli/core/services/import_result.py +56 -0
- elva_cli-0.5.0/src/elva_cli/core/services/import_spec.py +507 -0
- elva_cli-0.5.0/src/elva_cli/core/services/whoami.py +72 -0
- elva_cli-0.5.0/src/elva_cli/core/services/whoami_result.py +12 -0
- elva_cli-0.5.0/src/elva_cli/core/spec/detect.py +118 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/registry.py +2 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/__init__.py +2 -0
- elva_cli-0.5.0/src/elva_cli/ui/renderables/import_spec.py +92 -0
- elva_cli-0.5.0/src/elva_cli/ui/renderables/whoami.py +16 -0
- elva_cli-0.5.0/tests/cli/conftest.py +66 -0
- elva_cli-0.5.0/tests/cli/test_auth_logout_command.py +19 -0
- elva_cli-0.5.0/tests/cli/test_import_command.py +146 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/cli/test_never_blocks.py +1 -1
- elva_cli-0.5.0/tests/cli/test_whoami_command.py +17 -0
- elva_cli-0.5.0/tests/unit/test_http.py +188 -0
- elva_cli-0.5.0/tests/unit/test_import_flow.py +753 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_login_flow.py +1 -12
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_output.py +73 -0
- elva_cli-0.5.0/tests/unit/test_refresh_client.py +273 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_session.py +24 -0
- elva_cli-0.5.0/tests/unit/test_spec_detect.py +175 -0
- elva_cli-0.5.0/tests/unit/test_targets.py +147 -0
- elva_cli-0.5.0/tests/unit/test_whoami_flow.py +166 -0
- elva_cli-0.3.0/tests/cli/test_auth_logout_command.py +0 -52
- {elva_cli-0.3.0 → elva_cli-0.5.0}/.gitignore +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/__main__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/auth/models.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/auth/store.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/commands/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/commands/auth.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/commands/config.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/context.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/core/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/core/api/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/core/services/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/core/services/auth_result.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/core/services/config.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/core/spec/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/errors.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/logging.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/main.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/settings/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/settings/loader.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/settings/models.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/settings/paths.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/telemetry.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/console.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/output.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/prompts.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/auth.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/base.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/config.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/theme.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/views/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/update.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/cli/test_cli_exit_codes.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/cli/test_config_command.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/cli/test_lazy_imports.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/cli/test_output_streams.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_boundary.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_context.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_credentials.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_error_boundary.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_errors.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_exit_codes.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_interactivity.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_logout_flow.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_prompts.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_settings_loader.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_token_store.py +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Identify as a first-class refresh client (ELVA-200).** Every request now
|
|
7
|
+
sends `X-Elva-Client: cli` and a `User-Agent: elva-cli/<version>`. The refresh
|
|
8
|
+
and CLI-login exchanges carry the marker so the backend routes them on the
|
|
9
|
+
body-token path and, once its compatibility flag is turned off, does not
|
|
10
|
+
reject the CLI as an unmarked client.
|
|
11
|
+
- **Refresh-and-retry on 401.** An idempotent request (GET/HEAD) that returns
|
|
12
|
+
401 on a token that looked valid now forces one refresh and retries once.
|
|
13
|
+
Non-idempotent requests (the spec upload) are never auto-replayed.
|
|
14
|
+
- **Backoff on transient refresh failures.** A refresh that hits 429/5xx or a
|
|
15
|
+
network error is retried with backoff (1s/2s/4s) before surfacing as a
|
|
16
|
+
transient `ApiError` (exit 5); it never forces a re-login.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Refresh continues to happen proactively ~60s before the access token expires
|
|
20
|
+
and to persist the rotated token atomically (temp file + rename, 0600) before
|
|
21
|
+
the response is used — unchanged, but now covered by explicit tests.
|
|
22
|
+
|
|
23
|
+
### Compatibility
|
|
24
|
+
This version works with **both** the flagged and the unflagged backend:
|
|
25
|
+
- While the backend keeps `AUTH_ALLOW_BODY_REFRESH_TOKEN` on, unmarked body
|
|
26
|
+
refreshes still work, so **older CLI versions keep working** too.
|
|
27
|
+
- Once the backend turns that flag off (this CLI version being the minimum
|
|
28
|
+
supported), the server rejects unmarked and legacy ("family-less") refresh
|
|
29
|
+
tokens with `401 REFRESH_TOKEN_LEGACY`. This CLI maps that to
|
|
30
|
+
*"Your session has expired. Run `elva auth login` to sign in again."*
|
|
31
|
+
(exit code 3). **Older CLI versions stop working at that point** and users
|
|
32
|
+
must upgrade and re-login once.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: elva-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Elva - CLI for Theneo Elva
|
|
5
5
|
Project-URL: Homepage, https://getelva.ai
|
|
6
6
|
Project-URL: Source, https://github.com/Theneo-Inc/theneo-elva-cli
|
|
@@ -19,11 +19,14 @@ Requires-Python: >=3.11
|
|
|
19
19
|
Requires-Dist: keyring>=25
|
|
20
20
|
Requires-Dist: platformdirs>=4.2
|
|
21
21
|
Requires-Dist: pydantic>=2.7
|
|
22
|
+
Requires-Dist: pyyaml>=6.0
|
|
23
|
+
Requires-Dist: questionary>=2.0
|
|
22
24
|
Requires-Dist: typer<1.0,>=0.15
|
|
23
25
|
Provides-Extra: dev
|
|
24
26
|
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
25
27
|
Requires-Dist: pytest>=8.2; extra == 'dev'
|
|
26
28
|
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
29
|
+
Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
|
|
27
30
|
Description-Content-Type: text/markdown
|
|
28
31
|
|
|
29
32
|
# Elva CLI
|
|
@@ -31,8 +34,7 @@ Description-Content-Type: text/markdown
|
|
|
31
34
|
Manage your [Elva](https://getelva.ai) API projects from the terminal: import specs,
|
|
32
35
|
inspect collections, and generate MCP servers without opening a browser.
|
|
33
36
|
|
|
34
|
-
> **Early alpha.** The command surface is still taking shape.
|
|
35
|
-
> `--version` and `--help` only; the first working commands land in `0.1.0`.
|
|
37
|
+
> **Early alpha.** The command surface is still taking shape.
|
|
36
38
|
|
|
37
39
|
## Install
|
|
38
40
|
|
|
@@ -74,6 +76,120 @@ powershell -c "irm https://astral.sh/uv/install.ps1|iex" # Windows
|
|
|
74
76
|
uv tool upgrade elva-cli # or: pipx upgrade elva-cli
|
|
75
77
|
```
|
|
76
78
|
|
|
79
|
+
## Importing a spec
|
|
80
|
+
|
|
81
|
+
Create a collection from an OpenAPI document:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
elva import spec openapi.yaml
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
Created Payments Platform API from openapi.yaml.
|
|
89
|
+
|
|
90
|
+
workspace Theneo
|
|
91
|
+
format openapi
|
|
92
|
+
title Payments Platform API
|
|
93
|
+
version 2.4.1
|
|
94
|
+
endpoints 17
|
|
95
|
+
collection id 6aa1322d7ef06cc8f9017460
|
|
96
|
+
url https://app.getelva.ai/collections?selected=6aa1322d7ef06cc8f9017460
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The collection is named after the spec's `info.title`. Override it with `--name`, which
|
|
100
|
+
is also what you will be asked for if the spec has no title:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
elva import spec openapi.yaml --name "Payments v2"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Where the spec comes from
|
|
107
|
+
|
|
108
|
+
A path, a URL Elva fetches itself, or stdin:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
elva import spec openapi.yaml
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
elva import spec --url https://example.com/openapi.yaml --name Payments
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
curl -s https://example.com/openapi.yaml | elva import spec - --name Payments
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
A URL is fetched server-side, so nothing is read locally -- which is why `--name` cannot
|
|
123
|
+
be defaulted from it. Files must be `.json`, `.yaml` or `.yml`, and 10 MB or smaller.
|
|
124
|
+
|
|
125
|
+
### Updating an existing collection
|
|
126
|
+
|
|
127
|
+
Importing never overwrites. Replacing the spec of a collection that already exists is a
|
|
128
|
+
separate, explicit action:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
elva -c payments-api import spec openapi.yaml --update
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The previous spec is kept as a restorable version. `--collection` takes a name or an id,
|
|
135
|
+
and `--workspace` picks which workspace to look that name up in; an account with a single
|
|
136
|
+
workspace needs neither. Both are global flags, so they go before the subcommand, and both
|
|
137
|
+
can live in `elva.json` instead.
|
|
138
|
+
|
|
139
|
+
### Checking first
|
|
140
|
+
|
|
141
|
+
`--dry-run` reports what would happen and sends nothing at all -- it works signed out:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
elva import spec openapi.yaml --dry-run
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
Would create Payments Platform API from openapi.yaml.
|
|
149
|
+
|
|
150
|
+
format openapi
|
|
151
|
+
title Payments Platform API
|
|
152
|
+
version 2.4.1
|
|
153
|
+
endpoints 17
|
|
154
|
+
size 11.3 KB
|
|
155
|
+
|
|
156
|
+
Nothing was sent. Drop --dry-run to do it.
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Postman
|
|
160
|
+
|
|
161
|
+
Postman collections are not supported here. Elva reads operations out of an uploaded file
|
|
162
|
+
as OpenAPI, so a Postman collection would import successfully and produce an empty
|
|
163
|
+
collection -- `elva` refuses it rather than let that happen. Export to OpenAPI first, or
|
|
164
|
+
use the Postman integration in the web app.
|
|
165
|
+
|
|
166
|
+
### In CI
|
|
167
|
+
|
|
168
|
+
The exit code is the whole interface:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
elva import spec openapi.yaml
|
|
172
|
+
case $? in
|
|
173
|
+
0) echo "imported" ;;
|
|
174
|
+
2) echo "bad invocation - wrong name, missing file, name already taken"; exit 1 ;;
|
|
175
|
+
3) echo "not signed in"; exit 1 ;;
|
|
176
|
+
4) echo "the spec was rejected"; exit 1 ;;
|
|
177
|
+
5) echo "Elva unreachable"; exit 0 ;;
|
|
178
|
+
esac
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Note what `0` does and does not mean. Elva stores the file and extracts what it can, so a
|
|
182
|
+
spec it cannot parse imports *successfully* with no endpoints in it rather than failing.
|
|
183
|
+
The CLI says so plainly in that case, but the exit code still comes from the server. A
|
|
184
|
+
pipeline that cares should check the count:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
count=$(elva --json import spec openapi.yaml | jq '.endpoints // 0')
|
|
188
|
+
[ "$count" -gt 0 ] || { echo "spec produced no endpoints"; exit 1; }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
See [exit codes](docs/exit-codes.md) for the full table.
|
|
192
|
+
|
|
77
193
|
## Configuration
|
|
78
194
|
|
|
79
195
|
Settings can come from several places. Highest priority wins:
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
Manage your [Elva](https://getelva.ai) API projects from the terminal: import specs,
|
|
4
4
|
inspect collections, and generate MCP servers without opening a browser.
|
|
5
5
|
|
|
6
|
-
> **Early alpha.** The command surface is still taking shape.
|
|
7
|
-
> `--version` and `--help` only; the first working commands land in `0.1.0`.
|
|
6
|
+
> **Early alpha.** The command surface is still taking shape.
|
|
8
7
|
|
|
9
8
|
## Install
|
|
10
9
|
|
|
@@ -46,6 +45,120 @@ powershell -c "irm https://astral.sh/uv/install.ps1|iex" # Windows
|
|
|
46
45
|
uv tool upgrade elva-cli # or: pipx upgrade elva-cli
|
|
47
46
|
```
|
|
48
47
|
|
|
48
|
+
## Importing a spec
|
|
49
|
+
|
|
50
|
+
Create a collection from an OpenAPI document:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
elva import spec openapi.yaml
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
Created Payments Platform API from openapi.yaml.
|
|
58
|
+
|
|
59
|
+
workspace Theneo
|
|
60
|
+
format openapi
|
|
61
|
+
title Payments Platform API
|
|
62
|
+
version 2.4.1
|
|
63
|
+
endpoints 17
|
|
64
|
+
collection id 6aa1322d7ef06cc8f9017460
|
|
65
|
+
url https://app.getelva.ai/collections?selected=6aa1322d7ef06cc8f9017460
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The collection is named after the spec's `info.title`. Override it with `--name`, which
|
|
69
|
+
is also what you will be asked for if the spec has no title:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
elva import spec openapi.yaml --name "Payments v2"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Where the spec comes from
|
|
76
|
+
|
|
77
|
+
A path, a URL Elva fetches itself, or stdin:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
elva import spec openapi.yaml
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
elva import spec --url https://example.com/openapi.yaml --name Payments
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
curl -s https://example.com/openapi.yaml | elva import spec - --name Payments
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
A URL is fetched server-side, so nothing is read locally -- which is why `--name` cannot
|
|
92
|
+
be defaulted from it. Files must be `.json`, `.yaml` or `.yml`, and 10 MB or smaller.
|
|
93
|
+
|
|
94
|
+
### Updating an existing collection
|
|
95
|
+
|
|
96
|
+
Importing never overwrites. Replacing the spec of a collection that already exists is a
|
|
97
|
+
separate, explicit action:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
elva -c payments-api import spec openapi.yaml --update
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The previous spec is kept as a restorable version. `--collection` takes a name or an id,
|
|
104
|
+
and `--workspace` picks which workspace to look that name up in; an account with a single
|
|
105
|
+
workspace needs neither. Both are global flags, so they go before the subcommand, and both
|
|
106
|
+
can live in `elva.json` instead.
|
|
107
|
+
|
|
108
|
+
### Checking first
|
|
109
|
+
|
|
110
|
+
`--dry-run` reports what would happen and sends nothing at all -- it works signed out:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
elva import spec openapi.yaml --dry-run
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
Would create Payments Platform API from openapi.yaml.
|
|
118
|
+
|
|
119
|
+
format openapi
|
|
120
|
+
title Payments Platform API
|
|
121
|
+
version 2.4.1
|
|
122
|
+
endpoints 17
|
|
123
|
+
size 11.3 KB
|
|
124
|
+
|
|
125
|
+
Nothing was sent. Drop --dry-run to do it.
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Postman
|
|
129
|
+
|
|
130
|
+
Postman collections are not supported here. Elva reads operations out of an uploaded file
|
|
131
|
+
as OpenAPI, so a Postman collection would import successfully and produce an empty
|
|
132
|
+
collection -- `elva` refuses it rather than let that happen. Export to OpenAPI first, or
|
|
133
|
+
use the Postman integration in the web app.
|
|
134
|
+
|
|
135
|
+
### In CI
|
|
136
|
+
|
|
137
|
+
The exit code is the whole interface:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
elva import spec openapi.yaml
|
|
141
|
+
case $? in
|
|
142
|
+
0) echo "imported" ;;
|
|
143
|
+
2) echo "bad invocation - wrong name, missing file, name already taken"; exit 1 ;;
|
|
144
|
+
3) echo "not signed in"; exit 1 ;;
|
|
145
|
+
4) echo "the spec was rejected"; exit 1 ;;
|
|
146
|
+
5) echo "Elva unreachable"; exit 0 ;;
|
|
147
|
+
esac
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Note what `0` does and does not mean. Elva stores the file and extracts what it can, so a
|
|
151
|
+
spec it cannot parse imports *successfully* with no endpoints in it rather than failing.
|
|
152
|
+
The CLI says so plainly in that case, but the exit code still comes from the server. A
|
|
153
|
+
pipeline that cares should check the count:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
count=$(elva --json import spec openapi.yaml | jq '.endpoints // 0')
|
|
157
|
+
[ "$count" -gt 0 ] || { echo "spec produced no endpoints"; exit 1; }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
See [exit codes](docs/exit-codes.md) for the full table.
|
|
161
|
+
|
|
49
162
|
## Configuration
|
|
50
163
|
|
|
51
164
|
Settings can come from several places. Highest priority wins:
|
|
@@ -26,6 +26,11 @@ dependencies = [
|
|
|
26
26
|
"platformdirs>=4.2",
|
|
27
27
|
"pydantic>=2.7",
|
|
28
28
|
"keyring>=25",
|
|
29
|
+
# Reads info.title out of a spec so --name can default to it, and backs
|
|
30
|
+
# --dry-run's report. Parses JSON too, so one parser covers both formats.
|
|
31
|
+
"pyyaml>=6.0",
|
|
32
|
+
# ui/prompts.py has always imported this; it was simply never declared.
|
|
33
|
+
"questionary>=2.0",
|
|
29
34
|
]
|
|
30
35
|
|
|
31
36
|
[project.optional-dependencies]
|
|
@@ -33,6 +38,7 @@ dev = [
|
|
|
33
38
|
"ruff>=0.6",
|
|
34
39
|
"mypy>=1.11",
|
|
35
40
|
"pytest>=8.2",
|
|
41
|
+
"types-PyYAML>=6.0",
|
|
36
42
|
]
|
|
37
43
|
|
|
38
44
|
[project.urls]
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.5.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 5, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -17,9 +17,28 @@ from typing import TYPE_CHECKING
|
|
|
17
17
|
from elva_cli.auth.models import Credentials
|
|
18
18
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
|
-
from elva_cli.auth.session import
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
from elva_cli.auth.session import (
|
|
21
|
+
ENV_TOKEN,
|
|
22
|
+
current_identity,
|
|
23
|
+
forget_stored_credentials,
|
|
24
|
+
get_access_token,
|
|
25
|
+
logout,
|
|
26
|
+
refresh_now,
|
|
27
|
+
save_login,
|
|
28
|
+
save_pat,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"ENV_TOKEN",
|
|
33
|
+
"Credentials",
|
|
34
|
+
"current_identity",
|
|
35
|
+
"forget_stored_credentials",
|
|
36
|
+
"get_access_token",
|
|
37
|
+
"logout",
|
|
38
|
+
"refresh_now",
|
|
39
|
+
"save_login",
|
|
40
|
+
"save_pat",
|
|
41
|
+
]
|
|
23
42
|
|
|
24
43
|
_LAZY = frozenset(__all__) - {"Credentials"}
|
|
25
44
|
|
|
@@ -20,18 +20,20 @@ import contextlib
|
|
|
20
20
|
import json
|
|
21
21
|
import logging
|
|
22
22
|
import os
|
|
23
|
+
import time
|
|
23
24
|
from datetime import UTC, datetime, timedelta
|
|
24
25
|
from typing import TYPE_CHECKING, Any
|
|
25
26
|
|
|
26
27
|
from elva_cli.auth.models import Credentials
|
|
27
28
|
from elva_cli.auth.store import FileStore, KeyringStore, StoreUnavailableError, TokenStore
|
|
29
|
+
from elva_cli.core.api.identity import client_headers
|
|
28
30
|
from elva_cli.core.services.auth_result import LogoutResult as LogoutResult
|
|
29
31
|
from elva_cli.core.services.auth_result import LogoutStatus as LogoutStatus
|
|
30
32
|
from elva_cli.errors import ApiError, AuthError
|
|
31
33
|
from elva_cli.settings import paths
|
|
32
34
|
|
|
33
35
|
if TYPE_CHECKING:
|
|
34
|
-
from collections.abc import Iterator
|
|
36
|
+
from collections.abc import Callable, Iterator
|
|
35
37
|
|
|
36
38
|
_logger = logging.getLogger(__name__)
|
|
37
39
|
|
|
@@ -41,6 +43,16 @@ _LOCK_FILE = "refresh.lock"
|
|
|
41
43
|
_HTTP_TIMEOUT = 10
|
|
42
44
|
_LOGOUT_TIMEOUT = 5
|
|
43
45
|
|
|
46
|
+
# Backoff for a transiently-failing refresh (429 / 5xx / network): the initial
|
|
47
|
+
# attempt plus retries sleeping these seconds between them, then give up with an
|
|
48
|
+
# ApiError (transient, exit 5) - never a forced re-login.
|
|
49
|
+
_REFRESH_BACKOFF_SECONDS = (1.0, 2.0, 4.0)
|
|
50
|
+
|
|
51
|
+
# Distinct backend code (ELVA-200) meaning the refresh token predates the
|
|
52
|
+
# current format and the user must sign in again - purely informational here,
|
|
53
|
+
# since every 401/403 already routes to a re-login.
|
|
54
|
+
_LEGACY_REFRESH_CODE = "REFRESH_TOKEN_LEGACY"
|
|
55
|
+
|
|
44
56
|
|
|
45
57
|
class RefreshFailedError(Exception):
|
|
46
58
|
"""The backend positively rejected this refresh token (401/403) — the
|
|
@@ -151,26 +163,70 @@ def _refresh_lock() -> Iterator[None]:
|
|
|
151
163
|
os.close(fd)
|
|
152
164
|
|
|
153
165
|
|
|
154
|
-
def _refresh(
|
|
166
|
+
def _refresh(
|
|
167
|
+
refresh_token: str,
|
|
168
|
+
*,
|
|
169
|
+
base_url: str,
|
|
170
|
+
timeout: float = _HTTP_TIMEOUT,
|
|
171
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
172
|
+
) -> Credentials:
|
|
173
|
+
"""Spend the refresh token for a fresh pair.
|
|
174
|
+
|
|
175
|
+
Sends X-Elva-Client: cli so the backend answers on the body path and, once
|
|
176
|
+
its compat flag is off, doesn't reject us as an unmarked client. A 401/403
|
|
177
|
+
is terminal (RefreshFailedError -> re-login); 429/5xx and network failures
|
|
178
|
+
are transient and retried with backoff before surfacing as ApiError."""
|
|
155
179
|
import urllib.error
|
|
156
180
|
import urllib.request
|
|
157
181
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
182
|
+
last_transient: Exception | None = None
|
|
183
|
+
# Initial attempt (delay 0), then one retry per backoff delay.
|
|
184
|
+
for delay in (0.0, *_REFRESH_BACKOFF_SECONDS):
|
|
185
|
+
if delay:
|
|
186
|
+
sleep(delay)
|
|
187
|
+
request = urllib.request.Request(
|
|
188
|
+
f"{base_url}/api/auth/refresh-tokens",
|
|
189
|
+
data=json.dumps({"refreshToken": refresh_token}).encode("utf-8"),
|
|
190
|
+
headers={"Content-Type": "application/json", **client_headers()},
|
|
191
|
+
method="POST",
|
|
192
|
+
)
|
|
193
|
+
try:
|
|
194
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
195
|
+
raw = response.read()
|
|
196
|
+
except urllib.error.HTTPError as exc:
|
|
197
|
+
if exc.code in (401, 403):
|
|
198
|
+
# Terminal: the token is dead (expired, rotated away, revoked,
|
|
199
|
+
# or legacy). Note the backend code when it says so, then
|
|
200
|
+
# re-login. Not retried.
|
|
201
|
+
code = _error_code(exc)
|
|
202
|
+
detail = " (legacy token)" if code == _LEGACY_REFRESH_CODE else ""
|
|
203
|
+
raise RefreshFailedError(
|
|
204
|
+
f"backend rejected refresh token (HTTP {exc.code}){detail}"
|
|
205
|
+
) from exc
|
|
206
|
+
if exc.code == 429 or exc.code >= 500:
|
|
207
|
+
last_transient = exc
|
|
208
|
+
continue # transient - back off and retry
|
|
209
|
+
raise ApiError(f"Refreshing your session failed (HTTP {exc.code}).") from exc
|
|
210
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
211
|
+
last_transient = exc
|
|
212
|
+
continue # transient - back off and retry
|
|
213
|
+
else:
|
|
214
|
+
return _parse_refresh_body(raw)
|
|
215
|
+
|
|
216
|
+
raise ApiError("Could not reach the server to refresh your session.") from last_transient
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _error_code(exc: Any) -> str | None:
|
|
220
|
+
"""The backend's machine-readable error code (`data`), if it sent one."""
|
|
164
221
|
try:
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
except (urllib.error.URLError, TimeoutError) as exc:
|
|
172
|
-
raise ApiError("Could not reach the server to refresh your session.") from exc
|
|
222
|
+
payload = json.loads(exc.read())
|
|
223
|
+
except (OSError, ValueError):
|
|
224
|
+
return None
|
|
225
|
+
code = payload.get("data") if isinstance(payload, dict) else None
|
|
226
|
+
return code if isinstance(code, str) else None
|
|
227
|
+
|
|
173
228
|
|
|
229
|
+
def _parse_refresh_body(raw: bytes) -> Credentials:
|
|
174
230
|
unexpected = "The server returned an unexpected response while refreshing your session."
|
|
175
231
|
try:
|
|
176
232
|
body = json.loads(raw)
|
|
@@ -259,6 +315,58 @@ def _refresh_session(*, base_url: str) -> str:
|
|
|
259
315
|
return refreshed.access_token
|
|
260
316
|
|
|
261
317
|
|
|
318
|
+
def refresh_now(*, base_url: str, stale_access_token: str) -> str:
|
|
319
|
+
"""Force a refresh after a request 401'd on a token that looked valid by
|
|
320
|
+
expiry (the server rejected it early - password change, inactivity, etc.).
|
|
321
|
+
|
|
322
|
+
Bounded to sessions. Under the cross-process lock we re-read the store: if
|
|
323
|
+
another process already rotated (the stored access token differs from the
|
|
324
|
+
one that 401'd), return that instead of spending a second refresh - the
|
|
325
|
+
double-spend is exactly what would trip the backend's reuse detection.
|
|
326
|
+
Raises AuthError if there's no usable session to refresh."""
|
|
327
|
+
with _refresh_lock():
|
|
328
|
+
creds, _ = _load_from_first_available_store()
|
|
329
|
+
if (
|
|
330
|
+
creds is None
|
|
331
|
+
or creds.kind != "session"
|
|
332
|
+
or creds.refresh_token is None
|
|
333
|
+
or creds.refresh_expires_at is None
|
|
334
|
+
):
|
|
335
|
+
raise AuthError("Your session has expired.")
|
|
336
|
+
|
|
337
|
+
if creds.access_token != stale_access_token:
|
|
338
|
+
# A sibling process refreshed while we held the stale token.
|
|
339
|
+
return creds.access_token
|
|
340
|
+
|
|
341
|
+
if creds.refresh_expires_at <= datetime.now(UTC):
|
|
342
|
+
_clear_all_stores()
|
|
343
|
+
raise AuthError("Your session has expired.")
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
refreshed = _refresh(creds.refresh_token, base_url=base_url)
|
|
347
|
+
except RefreshFailedError as exc:
|
|
348
|
+
_clear_all_stores()
|
|
349
|
+
raise AuthError("Your session has expired.") from exc
|
|
350
|
+
|
|
351
|
+
_persist_refreshed(refreshed)
|
|
352
|
+
return refreshed.access_token
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def current_identity() -> str:
|
|
356
|
+
"""Where get_access_token(base_url=...) would source its token right now:
|
|
357
|
+
'env' (ELVA_TOKEN), 'pat' (a stored personal access token), 'session' (a
|
|
358
|
+
stored browser session), or 'none'."""
|
|
359
|
+
if os.environ.get(ENV_TOKEN):
|
|
360
|
+
return "env"
|
|
361
|
+
creds, _ = _load_from_first_available_store()
|
|
362
|
+
return "none" if creds is None else creds.kind
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def forget_stored_credentials() -> None:
|
|
366
|
+
"""Drop whatever the TokenStores hold."""
|
|
367
|
+
_clear_all_stores()
|
|
368
|
+
|
|
369
|
+
|
|
262
370
|
def save_login(payload: dict[str, Any]) -> None:
|
|
263
371
|
"""Persist a fresh OAuth session from the CLI login exchange.
|
|
264
372
|
|
|
@@ -284,7 +392,7 @@ def _revoke_server_side(
|
|
|
284
392
|
request = urllib.request.Request(
|
|
285
393
|
f"{base_url}/api/auth/logout",
|
|
286
394
|
data=b"",
|
|
287
|
-
headers={"Authorization": f"Bearer {access_token}"},
|
|
395
|
+
headers={"Authorization": f"Bearer {access_token}", **client_headers()},
|
|
288
396
|
method="POST",
|
|
289
397
|
)
|
|
290
398
|
try:
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path # noqa: TC003
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from elva_cli.context import get_ctx
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
name="import",
|
|
11
|
+
help="Import an API spec into a collection.",
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
STDIN = "-"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.callback()
|
|
19
|
+
def main() -> None:
|
|
20
|
+
"""Keeps `import` a command group for future `import github` subcommands."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command("spec")
|
|
24
|
+
def spec(
|
|
25
|
+
click_ctx: typer.Context,
|
|
26
|
+
file: Path | None = typer.Argument(
|
|
27
|
+
None,
|
|
28
|
+
metavar="[FILE]",
|
|
29
|
+
help="OpenAPI document to upload, or - to read one from stdin.",
|
|
30
|
+
),
|
|
31
|
+
url: str | None = typer.Option(
|
|
32
|
+
None, "--url", metavar="URL", help="Have Elva fetch the spec from here instead."
|
|
33
|
+
),
|
|
34
|
+
name: str | None = typer.Option(
|
|
35
|
+
None,
|
|
36
|
+
"--name",
|
|
37
|
+
metavar="NAME",
|
|
38
|
+
help="Name for the new collection. Defaults to the spec's info.title.",
|
|
39
|
+
),
|
|
40
|
+
update: bool = typer.Option(
|
|
41
|
+
False,
|
|
42
|
+
"--update",
|
|
43
|
+
help="Replace the spec of the existing --collection instead of creating one.",
|
|
44
|
+
),
|
|
45
|
+
format_: str | None = typer.Option(
|
|
46
|
+
None,
|
|
47
|
+
"--format",
|
|
48
|
+
metavar="FORMAT",
|
|
49
|
+
help="Skip detection and say what the file is: openapi or postman.",
|
|
50
|
+
),
|
|
51
|
+
dry_run: bool = typer.Option(
|
|
52
|
+
False, "--dry-run", help="Report what would be imported without sending anything."
|
|
53
|
+
),
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Create a collection from an OpenAPI document.
|
|
56
|
+
|
|
57
|
+
Reads a local file, stdin, or a URL Elva fetches itself. Creating is the
|
|
58
|
+
default; --update replaces the spec of the collection named by --collection.
|
|
59
|
+
"""
|
|
60
|
+
from elva_cli.core.services.import_spec import import_spec
|
|
61
|
+
from elva_cli.ui import prompts
|
|
62
|
+
|
|
63
|
+
ctx = get_ctx(click_ctx)
|
|
64
|
+
|
|
65
|
+
stdin: bytes | None = None
|
|
66
|
+
path: Path | None = file
|
|
67
|
+
if file is not None and str(file) == STDIN:
|
|
68
|
+
path = None
|
|
69
|
+
stdin = read_stdin()
|
|
70
|
+
|
|
71
|
+
result = import_spec(
|
|
72
|
+
base_url=ctx.settings.base_url,
|
|
73
|
+
workspace=ctx.settings.workspace,
|
|
74
|
+
path=path,
|
|
75
|
+
stdin=stdin,
|
|
76
|
+
spec_url=url,
|
|
77
|
+
name=name,
|
|
78
|
+
prompt_for_name=lambda: prompts.text(
|
|
79
|
+
None, prompt="Name for the new collection", flag="--name", ctx=ctx
|
|
80
|
+
),
|
|
81
|
+
collection=ctx.settings.collection,
|
|
82
|
+
update=update,
|
|
83
|
+
spec_format=format_,
|
|
84
|
+
dry_run=dry_run,
|
|
85
|
+
)
|
|
86
|
+
ctx.out.result(result)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def read_stdin() -> bytes:
|
|
90
|
+
"""The spec piped in. Commands own the stdin boundary; nothing below them
|
|
91
|
+
reads a stream."""
|
|
92
|
+
import sys
|
|
93
|
+
|
|
94
|
+
return sys.stdin.buffer.read()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from elva_cli.context import get_ctx
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(name="whoami", help="Show who you're signed in as.", add_completion=False)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.command()
|
|
11
|
+
def whoami(click_ctx: typer.Context) -> None:
|
|
12
|
+
"""Show the email (and, for a personal access token, the scoped company)
|
|
13
|
+
the current credentials belong to."""
|
|
14
|
+
from elva_cli.core.services.whoami import whoami as whoami_service
|
|
15
|
+
|
|
16
|
+
ctx = get_ctx(click_ctx)
|
|
17
|
+
result = whoami_service(base_url=ctx.settings.base_url)
|
|
18
|
+
ctx.out.result(result)
|