oed-cli 0.1.2__tar.gz → 0.1.4__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 (26) hide show
  1. {oed_cli-0.1.2/src/oed_cli.egg-info → oed_cli-0.1.4}/PKG-INFO +49 -4
  2. {oed_cli-0.1.2 → oed_cli-0.1.4}/README.md +48 -3
  3. {oed_cli-0.1.2 → oed_cli-0.1.4}/pyproject.toml +1 -1
  4. {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/__init__.py +6 -6
  5. oed_cli-0.1.4/src/oed_cli/auth.py +176 -0
  6. {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/invoke.py +43 -2
  7. {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/main.py +233 -1
  8. {oed_cli-0.1.2 → oed_cli-0.1.4/src/oed_cli.egg-info}/PKG-INFO +49 -4
  9. {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli.egg-info/SOURCES.txt +4 -1
  10. oed_cli-0.1.4/tests/test_auth.py +107 -0
  11. {oed_cli-0.1.2 → oed_cli-0.1.4}/tests/test_cli.py +1 -1
  12. {oed_cli-0.1.2 → oed_cli-0.1.4}/tests/test_dynamic.py +137 -15
  13. oed_cli-0.1.4/tests/test_invoke.py +200 -0
  14. {oed_cli-0.1.2 → oed_cli-0.1.4}/LICENSE +0 -0
  15. {oed_cli-0.1.2 → oed_cli-0.1.4}/setup.cfg +0 -0
  16. {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/__main__.py +0 -0
  17. {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/cli.py +0 -0
  18. {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/discovery.py +0 -0
  19. {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/dynamic.py +0 -0
  20. {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/errors.py +0 -0
  21. {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/http.py +0 -0
  22. {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli/py.typed +0 -0
  23. {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli.egg-info/dependency_links.txt +0 -0
  24. {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli.egg-info/entry_points.txt +0 -0
  25. {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli.egg-info/requires.txt +0 -0
  26. {oed_cli-0.1.2 → oed_cli-0.1.4}/src/oed_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oed-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: oed — openEuler Infra command line. Auto-discovered, AI-friendly.
5
5
  Author: oed-cli contributors
6
6
  License: Apache-2.0
@@ -97,7 +97,7 @@ pip install -e .
97
97
  Verify:
98
98
 
99
99
  ```bash
100
- oed --version # → oed, version 0.1.2
100
+ oed --version # → oed, version 0.1.4
101
101
  ```
102
102
 
103
103
  ---
@@ -230,6 +230,49 @@ oed schema cve | jq '.paths | keys'
230
230
 
231
231
  ---
232
232
 
233
+ ## AtomGit (`ag`) authentication
234
+
235
+ AtomGit operations authenticate through the `access_token` query parameter
236
+ declared on their spec. Store a personal access token once, and every
237
+ `oed ag ...` call uses it automatically:
238
+
239
+ ```bash
240
+ # Interactive (prompts for the token, never echoes it back)
241
+ oed ag login
242
+
243
+ # Non-interactive — good for CI / scripts
244
+ oed ag login --token <pat>
245
+
246
+ # Skip validating the token against AtomGit before storing
247
+ oed ag login --token <pat> --no-verify
248
+
249
+ # Just report whether a token is configured (no network, no prompt)
250
+ oed ag login --status
251
+
252
+ # Forget the stored token
253
+ oed ag logout
254
+ ```
255
+
256
+ Token storage:
257
+
258
+ - Windows: encrypted at rest for the current user via DPAPI (no extra deps).
259
+ - Elsewhere: base64, which is documented obfuscation, **not** encryption.
260
+ - Lives under the cache dir (`tokens/ag.json`); `oed cache clear` never
261
+ touches credentials.
262
+
263
+ Auto-injection on real calls:
264
+
265
+ - If the operation declares `access_token` and you don't pass one, the stored
266
+ token is filled in automatically — `oed ag listAuthenticatedUserIssues` just
267
+ works.
268
+ - An explicit `--access-token <pat>` always wins over the stored one.
269
+ - If the operation requires a token and none is available anywhere, you get a
270
+ clear `ag_token_missing` error with a hint, instead of an opaque gateway 401.
271
+ - `--dry-run` and request echo views mask the token as `<stored>` — the real
272
+ value only ever goes out on the wire.
273
+
274
+ ---
275
+
233
276
  ## Local development
234
277
 
235
278
  ### Clone and install (editable)
@@ -248,9 +291,10 @@ invocation. Drop it with `pip uninstall oed-cli` when you're done.
248
291
  python -m pytest -q
249
292
  ```
250
293
 
251
- 41 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
294
+ 58 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
252
295
  per-parameter flag coercion, the `API_`-prefix alias, the
253
- `resolve_runtime_gateway` no-fallback semantics, and every exit code path.
296
+ `resolve_runtime_gateway` no-fallback semantics, every exit code path, and
297
+ v0.4's `ag` token store (DPAPI/base64) + auto-injection.
254
298
  They monkeypatch the discovery layer so no gateway access is needed.
255
299
 
256
300
  ### Smoke-test against the live gateway
@@ -328,6 +372,7 @@ to eyeball `oed services` output every week.
328
372
  | `oed info` hangs or `waf_block` exit 2 | gateway unreachable / WAF | confirm `curl https://api-gateway.osinfra.cn`; see `context/discoverAPI.md` §6 |
329
373
  | Chinese output garbled on Windows | console codepage not UTF-8 | `chcp 65001`, or pipe `\| python`, or `PYTHONIOENCODING=utf-8 oed …` |
330
374
  | `error="spec_missing"` (exit 4) on a known service | upstream hasn't published the spec yet | wait for the gateway-side OpenAPI yaml; nothing to do on the oed side |
375
+ | `error="ag_token_missing"` on an `ag` call | operation needs a token, none stored | `oed ag login` (or pass `--access-token <pat>`) |
331
376
  | A `cve` call exits 2 (`waf_block`) | spec points to a `.test.osinfra.cn` host | already handled — `oed` reads `base_url` from the discovery feed (no fallback) and ignores the spec's `x-apigateway-backend.httpEndpoints.address` for the host |
332
377
 
333
378
  ### Offline mode
@@ -63,7 +63,7 @@ pip install -e .
63
63
  Verify:
64
64
 
65
65
  ```bash
66
- oed --version # → oed, version 0.1.2
66
+ oed --version # → oed, version 0.1.4
67
67
  ```
68
68
 
69
69
  ---
@@ -196,6 +196,49 @@ oed schema cve | jq '.paths | keys'
196
196
 
197
197
  ---
198
198
 
199
+ ## AtomGit (`ag`) authentication
200
+
201
+ AtomGit operations authenticate through the `access_token` query parameter
202
+ declared on their spec. Store a personal access token once, and every
203
+ `oed ag ...` call uses it automatically:
204
+
205
+ ```bash
206
+ # Interactive (prompts for the token, never echoes it back)
207
+ oed ag login
208
+
209
+ # Non-interactive — good for CI / scripts
210
+ oed ag login --token <pat>
211
+
212
+ # Skip validating the token against AtomGit before storing
213
+ oed ag login --token <pat> --no-verify
214
+
215
+ # Just report whether a token is configured (no network, no prompt)
216
+ oed ag login --status
217
+
218
+ # Forget the stored token
219
+ oed ag logout
220
+ ```
221
+
222
+ Token storage:
223
+
224
+ - Windows: encrypted at rest for the current user via DPAPI (no extra deps).
225
+ - Elsewhere: base64, which is documented obfuscation, **not** encryption.
226
+ - Lives under the cache dir (`tokens/ag.json`); `oed cache clear` never
227
+ touches credentials.
228
+
229
+ Auto-injection on real calls:
230
+
231
+ - If the operation declares `access_token` and you don't pass one, the stored
232
+ token is filled in automatically — `oed ag listAuthenticatedUserIssues` just
233
+ works.
234
+ - An explicit `--access-token <pat>` always wins over the stored one.
235
+ - If the operation requires a token and none is available anywhere, you get a
236
+ clear `ag_token_missing` error with a hint, instead of an opaque gateway 401.
237
+ - `--dry-run` and request echo views mask the token as `<stored>` — the real
238
+ value only ever goes out on the wire.
239
+
240
+ ---
241
+
199
242
  ## Local development
200
243
 
201
244
  ### Clone and install (editable)
@@ -214,9 +257,10 @@ invocation. Drop it with `pip uninstall oed-cli` when you're done.
214
257
  python -m pytest -q
215
258
  ```
216
259
 
217
- 41 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
260
+ 58 tests cover v0.1 + v0.2 dispatch, the operation-help cheatsheet,
218
261
  per-parameter flag coercion, the `API_`-prefix alias, the
219
- `resolve_runtime_gateway` no-fallback semantics, and every exit code path.
262
+ `resolve_runtime_gateway` no-fallback semantics, every exit code path, and
263
+ v0.4's `ag` token store (DPAPI/base64) + auto-injection.
220
264
  They monkeypatch the discovery layer so no gateway access is needed.
221
265
 
222
266
  ### Smoke-test against the live gateway
@@ -294,6 +338,7 @@ to eyeball `oed services` output every week.
294
338
  | `oed info` hangs or `waf_block` exit 2 | gateway unreachable / WAF | confirm `curl https://api-gateway.osinfra.cn`; see `context/discoverAPI.md` §6 |
295
339
  | Chinese output garbled on Windows | console codepage not UTF-8 | `chcp 65001`, or pipe `\| python`, or `PYTHONIOENCODING=utf-8 oed …` |
296
340
  | `error="spec_missing"` (exit 4) on a known service | upstream hasn't published the spec yet | wait for the gateway-side OpenAPI yaml; nothing to do on the oed side |
341
+ | `error="ag_token_missing"` on an `ag` call | operation needs a token, none stored | `oed ag login` (or pass `--access-token <pat>`) |
297
342
  | A `cve` call exits 2 (`waf_block`) | spec points to a `.test.osinfra.cn` host | already handled — `oed` reads `base_url` from the discovery feed (no fallback) and ignores the spec's `x-apigateway-backend.httpEndpoints.address` for the host |
298
343
 
299
344
  ### Offline mode
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oed-cli"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "oed — openEuler Infra command line. Auto-discovered, AI-friendly."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
- """oed-cli — openEuler Infra command line, auto-discovered, AI-friendly."""
2
-
3
- from __future__ import annotations
4
-
5
- __version__ = "0.1.2"
6
- __all__ = ["__version__"]
1
+ """oed-cli — openEuler Infra command line, auto-discovered, AI-friendly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.4"
6
+ __all__ = ["__version__"]
@@ -0,0 +1,176 @@
1
+ """Local secret storage for ``oed`` authentication tokens.
2
+
3
+ On Windows, tokens are encrypted at rest for the current Windows user via
4
+ DPAPI (``CryptProtectData`` / ``CryptUnprotectData`` through ctypes) — a real
5
+ encryption boundary with zero runtime dependencies. Non-Windows platforms fall
6
+ back to base64, which is documented obfuscation and NOT encryption.
7
+
8
+ The on-disk schema is versioned and keyed by ``service`` so a future general
9
+ ``oed login`` can share this store with the ``ag``-specific token here.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import contextlib
16
+ import ctypes
17
+ import datetime as _dt
18
+ import json
19
+ import platform
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from .discovery import _cache_dir
24
+ from .errors import UserError
25
+
26
+ _SCHEMA_VERSION = 1
27
+ _DPAPI_ENTROPY = b"oed-cli:ag:v1"
28
+ _CRYPTPROTECT_UI_FORBIDDEN = 0x1
29
+
30
+
31
+ class _DATA_BLOB(ctypes.Structure):
32
+ _fields_ = [("cbData", ctypes.c_ulong), ("pbData", ctypes.POINTER(ctypes.c_char))]
33
+
34
+
35
+ def _is_windows() -> bool:
36
+ return platform.system() == "Windows"
37
+
38
+
39
+ def token_path(service: str = "ag") -> Path:
40
+ """Return the on-disk path for ``service``'s encrypted token.
41
+
42
+ Lives under the shared cache dir (honors ``OED_CACHE_DIR``) in a ``tokens/``
43
+ subdirectory so ``oed cache clear`` (which only unlinks ``discovery.json``)
44
+ can never wipe credentials.
45
+ """
46
+
47
+ return _cache_dir() / "tokens" / f"{service}.json"
48
+
49
+
50
+ def _win32_crypt(data: bytes, *, protect: bool) -> bytes:
51
+ """Protect/unprotect ``data`` with DPAPI scoped to the current Windows user."""
52
+
53
+ windll = ctypes.windll
54
+ func = windll.crypt32.CryptProtectData if protect else windll.crypt32.CryptUnprotectData
55
+ func.argtypes = [
56
+ ctypes.POINTER(_DATA_BLOB), # pDataIn
57
+ ctypes.c_wchar_p, # szDataDescr
58
+ ctypes.POINTER(_DATA_BLOB), # pOptionalEntropy
59
+ ctypes.c_void_p, # pvReserved
60
+ ctypes.c_void_p, # pPromptStruct
61
+ ctypes.c_ulong, # dwFlags
62
+ ctypes.POINTER(_DATA_BLOB), # pDataOut
63
+ ]
64
+ func.restype = ctypes.c_int
65
+
66
+ # keep the backing buffers referenced for the duration of the call
67
+ inp = ctypes.create_string_buffer(data, len(data))
68
+ in_blob = _DATA_BLOB(len(data), ctypes.cast(inp, ctypes.POINTER(ctypes.c_char)))
69
+ ent = ctypes.create_string_buffer(_DPAPI_ENTROPY, len(_DPAPI_ENTROPY))
70
+ ent_blob = _DATA_BLOB(len(_DPAPI_ENTROPY), ctypes.cast(ent, ctypes.POINTER(ctypes.c_char)))
71
+ out_blob = _DATA_BLOB()
72
+
73
+ ok = func(
74
+ ctypes.byref(in_blob),
75
+ None,
76
+ ctypes.byref(ent_blob),
77
+ None,
78
+ None,
79
+ _CRYPTPROTECT_UI_FORBIDDEN,
80
+ ctypes.byref(out_blob),
81
+ )
82
+ if not ok:
83
+ side = "protect" if protect else "unprotect"
84
+ raise UserError(
85
+ f"Windows DPAPI could not {side} the token",
86
+ kind="dpapi_failed",
87
+ hint="Retry, or file an issue if it persists.",
88
+ )
89
+ try:
90
+ return ctypes.string_at(out_blob.pbData, out_blob.cbData)
91
+ finally:
92
+ windll.kernel32.LocalFree(out_blob.pbData)
93
+
94
+
95
+ def _encrypt(plain: str) -> tuple[str, str]:
96
+ """Return ``(base64 blob, schema encryption marker)`` for ``plain``."""
97
+
98
+ raw = plain.encode("utf-8")
99
+ if _is_windows():
100
+ return base64.b64encode(_win32_crypt(raw, protect=True)).decode("ascii"), "dpapi"
101
+ return base64.b64encode(raw).decode("ascii"), "base64"
102
+
103
+
104
+ def store_token(token: str, *, service: str = "ag") -> Path:
105
+ """Encrypt ``token`` and write it to disk for ``service``.
106
+
107
+ Never stores plaintext; raises :class:`UserError` if the write fails.
108
+ """
109
+
110
+ blob, encryption = _encrypt(token)
111
+ payload: dict[str, Any] = {
112
+ "version": _SCHEMA_VERSION,
113
+ "service": service,
114
+ "encryption": encryption,
115
+ "secret": blob,
116
+ "created_at": _dt.datetime.now(_dt.timezone.utc).isoformat(),
117
+ }
118
+ path = token_path(service)
119
+ try:
120
+ path.parent.mkdir(parents=True, exist_ok=True)
121
+ path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
122
+ except OSError as exc:
123
+ raise UserError(
124
+ f"could not write token store {path}: {exc}",
125
+ kind="token_store_write_failed",
126
+ hint="Check the directory is writable, or set OED_CACHE_DIR.",
127
+ ) from exc
128
+ return path
129
+
130
+
131
+ def read_token(service: str = "ag") -> str | None:
132
+ """Return the decrypted token for ``service``, or ``None``.
133
+
134
+ Any read failure (missing file, corrupt JSON, undecryptable blob) degrades
135
+ silently to ``None`` so callers fall through to the missing-token path.
136
+ """
137
+
138
+ try:
139
+ raw = json.loads(token_path(service).read_text(encoding="utf-8"))
140
+ if raw.get("version") != _SCHEMA_VERSION or raw.get("service") != service:
141
+ return None
142
+ secret = base64.b64decode(raw["secret"])
143
+ if raw.get("encryption") == "dpapi" and _is_windows():
144
+ return _win32_crypt(secret, protect=False).decode("utf-8")
145
+ if raw.get("encryption") == "base64":
146
+ return secret.decode("utf-8")
147
+ return None
148
+ except Exception:
149
+ return None
150
+
151
+
152
+ def token_info(service: str = "ag") -> dict[str, Any] | None:
153
+ """Return ``{encryption, created_at}`` metadata for the stored token, or ``None``."""
154
+
155
+ try:
156
+ raw = json.loads(token_path(service).read_text(encoding="utf-8"))
157
+ return {
158
+ "encryption": raw.get("encryption"),
159
+ "created_at": raw.get("created_at"),
160
+ }
161
+ except Exception:
162
+ return None
163
+
164
+
165
+ def clear_token(service: str = "ag") -> bool:
166
+ """Delete ``service``'s stored token; return whether one existed."""
167
+
168
+ path = token_path(service)
169
+ if not path.is_file():
170
+ return False
171
+ with contextlib.suppress(OSError):
172
+ path.unlink()
173
+ return True
174
+
175
+
176
+ __all__ = ["clear_token", "read_token", "store_token", "token_info", "token_path"]
@@ -24,6 +24,7 @@ from typing import Any
24
24
  import httpx
25
25
 
26
26
  from . import http as http_mod
27
+ from .auth import read_token
27
28
  from .dynamic import Operation, coerce_param_types, to_flag
28
29
  from .errors import NetworkError, UserError
29
30
  from .http import _is_waf_block, _resolve_user_agent
@@ -126,6 +127,33 @@ def _render_response(resp: httpx.Response) -> Any:
126
127
  }
127
128
 
128
129
 
130
+ def _inject_ag_token(op: Operation, query_params: dict[str, Any]) -> None:
131
+ """Auto-fill the ``access_token`` query param for AtomGit operations.
132
+
133
+ ``ag`` authenticates every request through the ``access_token`` query
134
+ parameter declared on its spec (用户授权码). When the caller didn't
135
+ pass one explicitly, fall back to the locally stored token from
136
+ ``oed ag login``. A required token that isn't available anywhere
137
+ raises a hint instead of failing inside the gateway; optional ones
138
+ simply go without.
139
+ """
140
+
141
+ declared = next(
142
+ (p for p in op.query_params if p.get("name") == "access_token"), None
143
+ )
144
+ if op.service_name != "ag" or declared is None or "access_token" in query_params:
145
+ return
146
+ token = read_token("ag")
147
+ if token:
148
+ query_params["access_token"] = token
149
+ elif declared.get("required"):
150
+ raise UserError(
151
+ "this AtomGit operation requires an access_token",
152
+ kind="ag_token_missing",
153
+ hint="Store one with `oed ag login`, or pass `--access-token <pat>`.",
154
+ )
155
+
156
+
129
157
  def call_operation(
130
158
  op: Operation,
131
159
  *,
@@ -158,16 +186,28 @@ def call_operation(
158
186
  )
159
187
 
160
188
  query_params = {k: v for k, v in query_params.items() if v is not None}
189
+ _inject_ag_token(op, query_params)
161
190
  url = f"{op.base_url}{filled_path}"
162
191
 
192
+ # Discourse (forum) authenticates via Api-Key/Api-Username; inert sentinel
193
+ # until login lands, so the request shape stays visible without leaking a real credential.
194
+ api_headers: dict[str, str] = {}
195
+ if op.service_name == "forum":
196
+ api_headers = {"Api-Key": "oed-placeholder", "Api-Username": "oed-placeholder"}
197
+
163
198
  request_headers: dict[str, str] = {"User-Agent": _resolve_user_agent(user_agent)}
164
199
  if body is not None:
165
200
  request_headers["Content-Type"] = "application/json"
201
+ # Mask credentials in any echoed request view (dry-run / include_request):
202
+ # the real token still goes out on the wire, it just never round-trips
203
+ # back to the terminal.
166
204
  request_view: dict[str, Any] = {
167
205
  "method": op.backend.method,
168
206
  "url": url,
169
- "query": query_params,
170
- "headers": request_headers,
207
+ "query": {
208
+ k: ("<stored>" if k == "access_token" else v) for k, v in query_params.items()
209
+ },
210
+ "headers": {**request_headers, **api_headers},
171
211
  "body": body,
172
212
  }
173
213
 
@@ -191,6 +231,7 @@ def call_operation(
191
231
  url,
192
232
  params=query_params if query_params else None,
193
233
  body=body,
234
+ headers=api_headers,
194
235
  timeout=timeout,
195
236
  user_agent=user_agent,
196
237
  )