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.
Files changed (85) hide show
  1. elva_cli-0.5.0/CHANGELOG.md +32 -0
  2. {elva_cli-0.3.0 → elva_cli-0.5.0}/PKG-INFO +119 -3
  3. {elva_cli-0.3.0 → elva_cli-0.5.0}/README.md +115 -2
  4. {elva_cli-0.3.0 → elva_cli-0.5.0}/pyproject.toml +6 -0
  5. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/_version.py +2 -2
  6. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/auth/__init__.py +22 -3
  7. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/auth/session.py +125 -17
  8. elva_cli-0.5.0/src/elva_cli/commands/import_.py +94 -0
  9. elva_cli-0.5.0/src/elva_cli/commands/whoami.py +18 -0
  10. elva_cli-0.5.0/src/elva_cli/core/api/http.py +254 -0
  11. elva_cli-0.5.0/src/elva_cli/core/api/identity.py +21 -0
  12. elva_cli-0.5.0/src/elva_cli/core/api/targets.py +150 -0
  13. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/core/services/auth.py +2 -1
  14. elva_cli-0.5.0/src/elva_cli/core/services/import_result.py +56 -0
  15. elva_cli-0.5.0/src/elva_cli/core/services/import_spec.py +507 -0
  16. elva_cli-0.5.0/src/elva_cli/core/services/whoami.py +72 -0
  17. elva_cli-0.5.0/src/elva_cli/core/services/whoami_result.py +12 -0
  18. elva_cli-0.5.0/src/elva_cli/core/spec/detect.py +118 -0
  19. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/registry.py +2 -0
  20. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/__init__.py +2 -0
  21. elva_cli-0.5.0/src/elva_cli/ui/renderables/import_spec.py +92 -0
  22. elva_cli-0.5.0/src/elva_cli/ui/renderables/whoami.py +16 -0
  23. elva_cli-0.5.0/tests/cli/conftest.py +66 -0
  24. elva_cli-0.5.0/tests/cli/test_auth_logout_command.py +19 -0
  25. elva_cli-0.5.0/tests/cli/test_import_command.py +146 -0
  26. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/cli/test_never_blocks.py +1 -1
  27. elva_cli-0.5.0/tests/cli/test_whoami_command.py +17 -0
  28. elva_cli-0.5.0/tests/unit/test_http.py +188 -0
  29. elva_cli-0.5.0/tests/unit/test_import_flow.py +753 -0
  30. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_login_flow.py +1 -12
  31. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_output.py +73 -0
  32. elva_cli-0.5.0/tests/unit/test_refresh_client.py +273 -0
  33. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_session.py +24 -0
  34. elva_cli-0.5.0/tests/unit/test_spec_detect.py +175 -0
  35. elva_cli-0.5.0/tests/unit/test_targets.py +147 -0
  36. elva_cli-0.5.0/tests/unit/test_whoami_flow.py +166 -0
  37. elva_cli-0.3.0/tests/cli/test_auth_logout_command.py +0 -52
  38. {elva_cli-0.3.0 → elva_cli-0.5.0}/.gitignore +0 -0
  39. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/__init__.py +0 -0
  40. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/__main__.py +0 -0
  41. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/auth/models.py +0 -0
  42. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/auth/store.py +0 -0
  43. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/commands/__init__.py +0 -0
  44. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/commands/auth.py +0 -0
  45. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/commands/config.py +0 -0
  46. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/context.py +0 -0
  47. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/core/__init__.py +0 -0
  48. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/core/api/__init__.py +0 -0
  49. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/core/services/__init__.py +0 -0
  50. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/core/services/auth_result.py +0 -0
  51. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/core/services/config.py +0 -0
  52. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/core/spec/__init__.py +0 -0
  53. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/errors.py +0 -0
  54. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/logging.py +0 -0
  55. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/main.py +0 -0
  56. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/settings/__init__.py +0 -0
  57. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/settings/loader.py +0 -0
  58. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/settings/models.py +0 -0
  59. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/settings/paths.py +0 -0
  60. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/telemetry.py +0 -0
  61. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/__init__.py +0 -0
  62. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/console.py +0 -0
  63. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/output.py +0 -0
  64. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/prompts.py +0 -0
  65. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/auth.py +0 -0
  66. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/base.py +0 -0
  67. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/renderables/config.py +0 -0
  68. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/theme.py +0 -0
  69. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/ui/views/__init__.py +0 -0
  70. {elva_cli-0.3.0 → elva_cli-0.5.0}/src/elva_cli/update.py +0 -0
  71. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/cli/test_cli_exit_codes.py +0 -0
  72. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/cli/test_config_command.py +0 -0
  73. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/cli/test_lazy_imports.py +0 -0
  74. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/cli/test_output_streams.py +0 -0
  75. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_boundary.py +0 -0
  76. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_context.py +0 -0
  77. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_credentials.py +0 -0
  78. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_error_boundary.py +0 -0
  79. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_errors.py +0 -0
  80. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_exit_codes.py +0 -0
  81. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_interactivity.py +0 -0
  82. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_logout_flow.py +0 -0
  83. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_prompts.py +0 -0
  84. {elva_cli-0.3.0 → elva_cli-0.5.0}/tests/unit/test_settings_loader.py +0 -0
  85. {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.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. This release ships
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. This release ships
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.3.0'
22
- __version_tuple__ = version_tuple = (0, 3, 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 ENV_TOKEN, get_access_token, logout, save_login, save_pat
21
-
22
- __all__ = ["ENV_TOKEN", "Credentials", "get_access_token", "logout", "save_login", "save_pat"]
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(refresh_token: str, *, base_url: str, timeout: float = _HTTP_TIMEOUT) -> Credentials:
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
- request = urllib.request.Request(
159
- f"{base_url}/api/auth/refresh-tokens",
160
- data=json.dumps({"refreshToken": refresh_token}).encode("utf-8"),
161
- headers={"Content-Type": "application/json"},
162
- method="POST",
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
- with urllib.request.urlopen(request, timeout=timeout) as response:
166
- raw = response.read()
167
- except urllib.error.HTTPError as exc:
168
- if exc.code in (401, 403):
169
- raise RefreshFailedError(f"backend rejected refresh token (HTTP {exc.code})") from exc
170
- raise ApiError(f"Refreshing your session failed (HTTP {exc.code}).") from exc
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)