elva-cli 0.2.0__tar.gz → 0.4.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.2.0 → elva_cli-0.4.0}/PKG-INFO +120 -3
- {elva_cli-0.2.0 → elva_cli-0.4.0}/README.md +115 -2
- {elva_cli-0.2.0 → elva_cli-0.4.0}/pyproject.toml +7 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/_version.py +2 -2
- elva_cli-0.4.0/src/elva_cli/auth/__init__.py +49 -0
- elva_cli-0.4.0/src/elva_cli/auth/models.py +100 -0
- elva_cli-0.4.0/src/elva_cli/auth/session.py +358 -0
- elva_cli-0.4.0/src/elva_cli/auth/store.py +121 -0
- elva_cli-0.4.0/src/elva_cli/commands/auth.py +37 -0
- elva_cli-0.4.0/src/elva_cli/commands/import_.py +94 -0
- elva_cli-0.4.0/src/elva_cli/commands/whoami.py +18 -0
- elva_cli-0.4.0/src/elva_cli/core/api/http.py +213 -0
- elva_cli-0.4.0/src/elva_cli/core/api/targets.py +132 -0
- elva_cli-0.4.0/src/elva_cli/core/services/auth.py +197 -0
- elva_cli-0.4.0/src/elva_cli/core/services/auth_result.py +40 -0
- elva_cli-0.4.0/src/elva_cli/core/services/import_result.py +56 -0
- elva_cli-0.4.0/src/elva_cli/core/services/import_spec.py +493 -0
- elva_cli-0.4.0/src/elva_cli/core/services/whoami.py +80 -0
- elva_cli-0.4.0/src/elva_cli/core/services/whoami_result.py +12 -0
- elva_cli-0.4.0/src/elva_cli/core/spec/detect.py +118 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/registry.py +3 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/renderables/__init__.py +3 -0
- elva_cli-0.4.0/src/elva_cli/ui/renderables/auth.py +25 -0
- elva_cli-0.4.0/src/elva_cli/ui/renderables/import_spec.py +92 -0
- elva_cli-0.4.0/src/elva_cli/ui/renderables/whoami.py +16 -0
- elva_cli-0.4.0/tests/cli/conftest.py +66 -0
- elva_cli-0.4.0/tests/cli/test_auth_logout_command.py +19 -0
- elva_cli-0.4.0/tests/cli/test_import_command.py +146 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/cli/test_never_blocks.py +14 -3
- elva_cli-0.4.0/tests/cli/test_whoami_command.py +17 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_context.py +47 -0
- elva_cli-0.4.0/tests/unit/test_credentials.py +83 -0
- elva_cli-0.4.0/tests/unit/test_http.py +188 -0
- elva_cli-0.4.0/tests/unit/test_import_flow.py +753 -0
- elva_cli-0.4.0/tests/unit/test_login_flow.py +307 -0
- elva_cli-0.4.0/tests/unit/test_logout_flow.py +35 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_output.py +73 -0
- elva_cli-0.4.0/tests/unit/test_session.py +788 -0
- elva_cli-0.4.0/tests/unit/test_spec_detect.py +175 -0
- elva_cli-0.4.0/tests/unit/test_targets.py +147 -0
- elva_cli-0.4.0/tests/unit/test_token_store.py +199 -0
- elva_cli-0.4.0/tests/unit/test_whoami_flow.py +166 -0
- elva_cli-0.2.0/src/elva_cli/auth/__init__.py +0 -8
- {elva_cli-0.2.0 → elva_cli-0.4.0}/.gitignore +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/__main__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/commands/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/commands/config.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/context.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/core/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/core/api/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/core/services/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/core/services/config.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/core/spec/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/errors.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/logging.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/main.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/settings/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/settings/loader.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/settings/models.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/settings/paths.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/telemetry.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/console.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/output.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/prompts.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/renderables/base.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/renderables/config.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/theme.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/ui/views/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/src/elva_cli/update.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/cli/test_cli_exit_codes.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/cli/test_config_command.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/cli/test_lazy_imports.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/cli/test_output_streams.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_boundary.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_error_boundary.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_errors.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_exit_codes.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_interactivity.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_prompts.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.4.0}/tests/unit/test_settings_loader.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: elva-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -16,13 +16,17 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
16
16
|
Classifier: Topic :: Software Development :: Documentation
|
|
17
17
|
Classifier: Typing :: Typed
|
|
18
18
|
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: keyring>=25
|
|
19
20
|
Requires-Dist: platformdirs>=4.2
|
|
20
21
|
Requires-Dist: pydantic>=2.7
|
|
22
|
+
Requires-Dist: pyyaml>=6.0
|
|
23
|
+
Requires-Dist: questionary>=2.0
|
|
21
24
|
Requires-Dist: typer<1.0,>=0.15
|
|
22
25
|
Provides-Extra: dev
|
|
23
26
|
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
24
27
|
Requires-Dist: pytest>=8.2; extra == 'dev'
|
|
25
28
|
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
29
|
+
Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
|
|
26
30
|
Description-Content-Type: text/markdown
|
|
27
31
|
|
|
28
32
|
# Elva CLI
|
|
@@ -30,8 +34,7 @@ Description-Content-Type: text/markdown
|
|
|
30
34
|
Manage your [Elva](https://getelva.ai) API projects from the terminal: import specs,
|
|
31
35
|
inspect collections, and generate MCP servers without opening a browser.
|
|
32
36
|
|
|
33
|
-
> **Early alpha.** The command surface is still taking shape.
|
|
34
|
-
> `--version` and `--help` only; the first working commands land in `0.1.0`.
|
|
37
|
+
> **Early alpha.** The command surface is still taking shape.
|
|
35
38
|
|
|
36
39
|
## Install
|
|
37
40
|
|
|
@@ -73,6 +76,120 @@ powershell -c "irm https://astral.sh/uv/install.ps1|iex" # Windows
|
|
|
73
76
|
uv tool upgrade elva-cli # or: pipx upgrade elva-cli
|
|
74
77
|
```
|
|
75
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
|
+
|
|
76
193
|
## Configuration
|
|
77
194
|
|
|
78
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:
|
|
@@ -25,6 +25,12 @@ dependencies = [
|
|
|
25
25
|
"typer>=0.15,<1.0",
|
|
26
26
|
"platformdirs>=4.2",
|
|
27
27
|
"pydantic>=2.7",
|
|
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",
|
|
28
34
|
]
|
|
29
35
|
|
|
30
36
|
[project.optional-dependencies]
|
|
@@ -32,6 +38,7 @@ dev = [
|
|
|
32
38
|
"ruff>=0.6",
|
|
33
39
|
"mypy>=1.11",
|
|
34
40
|
"pytest>=8.2",
|
|
41
|
+
"types-PyYAML>=6.0",
|
|
35
42
|
]
|
|
36
43
|
|
|
37
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.4.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Credentials and the login flow.
|
|
2
|
+
|
|
3
|
+
Token storage sits behind a Protocol (keyring, with a 0600 file fallback for
|
|
4
|
+
headless Linux and containers). ELVA_TOKEN overrides both in CI. A session's
|
|
5
|
+
access token refreshes itself transparently on a near-expiry read; a dead
|
|
6
|
+
session raises AuthError (exit code 3).
|
|
7
|
+
|
|
8
|
+
The browser handoff needs a /auth/cli endpoint on the JWT side of the
|
|
9
|
+
backend. The cookie-session auth used by the catalog and GitHub routes is
|
|
10
|
+
deliberately out of scope: a CLI has no cookie jar.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from elva_cli.auth.models import Credentials
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from elva_cli.auth.session import (
|
|
21
|
+
ENV_TOKEN,
|
|
22
|
+
current_identity,
|
|
23
|
+
forget_stored_credentials,
|
|
24
|
+
get_access_token,
|
|
25
|
+
logout,
|
|
26
|
+
save_login,
|
|
27
|
+
save_pat,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ENV_TOKEN",
|
|
32
|
+
"Credentials",
|
|
33
|
+
"current_identity",
|
|
34
|
+
"forget_stored_credentials",
|
|
35
|
+
"get_access_token",
|
|
36
|
+
"logout",
|
|
37
|
+
"save_login",
|
|
38
|
+
"save_pat",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
_LAZY = frozenset(__all__) - {"Credentials"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def __getattr__(name: str) -> object:
|
|
45
|
+
if name in _LAZY:
|
|
46
|
+
from elva_cli.auth import session
|
|
47
|
+
|
|
48
|
+
return getattr(session, name)
|
|
49
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The Credentials shape: what gets stored, and how it maps to the wire."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
TokenKind = Literal["session", "pat"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _parse_expiry(value: str) -> datetime:
|
|
13
|
+
"""Parse an ISO-8601 timestamp, forcing it timezone-aware.
|
|
14
|
+
|
|
15
|
+
The backend sends UTC with a `Z`, but a hand-edited credentials.json can
|
|
16
|
+
drop the offset. A naive datetime would later crash get_access_token's
|
|
17
|
+
`expires - datetime.now(UTC)` arithmetic, so assume UTC when none is
|
|
18
|
+
given."""
|
|
19
|
+
parsed = datetime.fromisoformat(value)
|
|
20
|
+
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Credentials:
|
|
25
|
+
"""One stored credential: either an OAuth session (JWT pair, refreshable)
|
|
26
|
+
or a Personal Access Token (opaque, long-lived, no refresh side)."""
|
|
27
|
+
|
|
28
|
+
kind: TokenKind
|
|
29
|
+
access_token: str
|
|
30
|
+
access_expires_at: datetime | None
|
|
31
|
+
refresh_token: str | None
|
|
32
|
+
refresh_expires_at: datetime | None
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_auth_tokens(cls, tokens: dict[str, Any]) -> Credentials:
|
|
36
|
+
"""Build a session credential from the backend's AuthTokens shape:
|
|
37
|
+
{"access": {"token", "expires"}, "refresh": {"token", "expires"}}.
|
|
38
|
+
|
|
39
|
+
POST /api/auth/refresh-tokens returns this shape at the top level.
|
|
40
|
+
The CLI login exchange (POST /api/auth/cli/token) nests it under a
|
|
41
|
+
"tokens" key alongside "user" — callers there go through
|
|
42
|
+
`session.save_login`, which unwraps it first."""
|
|
43
|
+
access = tokens["access"]
|
|
44
|
+
refresh = tokens["refresh"]
|
|
45
|
+
return cls(
|
|
46
|
+
kind="session",
|
|
47
|
+
access_token=access["token"],
|
|
48
|
+
access_expires_at=_parse_expiry(access["expires"]),
|
|
49
|
+
refresh_token=refresh["token"],
|
|
50
|
+
refresh_expires_at=_parse_expiry(refresh["expires"]),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_pat(cls, token: str) -> Credentials:
|
|
55
|
+
return cls(
|
|
56
|
+
kind="pat",
|
|
57
|
+
access_token=token,
|
|
58
|
+
access_expires_at=None,
|
|
59
|
+
refresh_token=None,
|
|
60
|
+
refresh_expires_at=None,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def to_json(self) -> dict[str, Any]:
|
|
64
|
+
return {
|
|
65
|
+
"kind": self.kind,
|
|
66
|
+
"access_token": self.access_token,
|
|
67
|
+
"access_expires_at": (
|
|
68
|
+
self.access_expires_at.isoformat() if self.access_expires_at else None
|
|
69
|
+
),
|
|
70
|
+
"refresh_token": self.refresh_token,
|
|
71
|
+
"refresh_expires_at": (
|
|
72
|
+
self.refresh_expires_at.isoformat() if self.refresh_expires_at else None
|
|
73
|
+
),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_json(cls, data: dict[str, Any]) -> Credentials:
|
|
78
|
+
kind = data["kind"]
|
|
79
|
+
if kind not in ("session", "pat"):
|
|
80
|
+
raise ValueError(f"unknown credential kind: {kind!r}")
|
|
81
|
+
creds = cls(
|
|
82
|
+
kind=kind,
|
|
83
|
+
access_token=data["access_token"],
|
|
84
|
+
access_expires_at=(
|
|
85
|
+
_parse_expiry(data["access_expires_at"]) if data.get("access_expires_at") else None
|
|
86
|
+
),
|
|
87
|
+
refresh_token=data.get("refresh_token"),
|
|
88
|
+
refresh_expires_at=(
|
|
89
|
+
_parse_expiry(data["refresh_expires_at"])
|
|
90
|
+
if data.get("refresh_expires_at")
|
|
91
|
+
else None
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
if creds.kind == "session" and (
|
|
95
|
+
creds.access_expires_at is None
|
|
96
|
+
or creds.refresh_token is None
|
|
97
|
+
or creds.refresh_expires_at is None
|
|
98
|
+
):
|
|
99
|
+
raise ValueError("incomplete session credential")
|
|
100
|
+
return creds
|