reportify-cli 0.1.46__tar.gz → 0.1.48__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 (45) hide show
  1. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/PKG-INFO +1 -1
  2. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/pyproject.toml +1 -1
  3. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/auth_config.py +49 -8
  4. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/commands/auth.py +99 -68
  5. reportify_cli-0.1.48/src/settings.py +87 -0
  6. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/tests/test_commands/test_auth_commands.py +87 -42
  7. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/uv.lock +2 -2
  8. reportify_cli-0.1.46/src/settings.py +0 -55
  9. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/.gitignore +0 -0
  10. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/Makefile +0 -0
  11. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/README.md +0 -0
  12. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/scripts/README.md +0 -0
  13. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/scripts/bump_version.sh +0 -0
  14. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/scripts/publish.sh +0 -0
  15. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/skills/reportify-agent/SKILL.md +0 -0
  16. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/skills/reportify-ai/SKILL.md +0 -0
  17. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/skills/reportify-ai/references/API_REFERENCE.md +0 -0
  18. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/skills/reportify-ai/references/COMMANDS.md +0 -0
  19. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/__init__.py +0 -0
  20. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/client.py +0 -0
  21. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/commands/__init__.py +0 -0
  22. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/commands/agent.py +0 -0
  23. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/commands/channels.py +0 -0
  24. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/commands/concepts.py +0 -0
  25. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/commands/docs.py +0 -0
  26. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/commands/following.py +0 -0
  27. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/commands/kb.py +0 -0
  28. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/commands/macro.py +0 -0
  29. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/commands/quant.py +0 -0
  30. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/commands/search.py +0 -0
  31. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/commands/stock.py +0 -0
  32. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/commands/timeline.py +0 -0
  33. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/commands/user.py +0 -0
  34. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/main.py +0 -0
  35. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/output.py +0 -0
  36. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/params.py +0 -0
  37. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/src/utils.py +0 -0
  38. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/tests/__init__.py +0 -0
  39. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/tests/integration/test_docs_integration.py +0 -0
  40. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/tests/integration/test_stock_integration.py +0 -0
  41. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/tests/test_commands/__init__.py +0 -0
  42. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/tests/test_commands/test_auth_config.py +0 -0
  43. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/tests/test_commands/test_docs.py +0 -0
  44. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/tests/test_commands/test_quant.py +0 -0
  45. {reportify_cli-0.1.46 → reportify_cli-0.1.48}/tests/test_commands/test_search.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reportify-cli
3
- Version: 0.1.46
3
+ Version: 0.1.48
4
4
  Summary: CLI wrapper for Reportify SDK - Access Reportify API through command line
5
5
  Project-URL: Homepage, https://reportify.ai
6
6
  Project-URL: Documentation, https://docs.reportify.ai
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "reportify-cli"
3
- version = "0.1.46"
3
+ version = "0.1.48"
4
4
  description = "CLI wrapper for Reportify SDK - Access Reportify API through command line"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -2,15 +2,26 @@
2
2
 
3
3
  存储位置:``~/.reportify/config`` (INI 格式),权限 0600。
4
4
 
5
+ 新 OAuth flow (推荐):
6
+
7
+ ```ini
8
+ [default]
9
+ access_token = eyJhbGciOiJIUzI1NiIs... # JWT
10
+ refresh_token = q9N1f...
11
+ expires_at = 1716800000 # epoch seconds
12
+ user_id = 12345
13
+ ```
14
+
15
+ 老 API Key flow (向后兼容):
16
+
5
17
  ```ini
6
18
  [default]
7
19
  api_key = 12345abcdef...
8
20
  user_id = 12345
9
- nickname = Foo
10
- email = foo@bar.com
11
- subscription_tier = plus
12
21
  ```
13
22
 
23
+ 两种字段可共存:CLI 读取时优先用未过期的 access_token,回退到 api_key。
24
+
14
25
  仅 first-party CLI 内部使用;Skill 不要直接读这个文件,应通过 ``reportify-cli`` 间接使用。
15
26
  """
16
27
 
@@ -30,16 +41,34 @@ CONFIG_PATH = CONFIG_DIR / "config"
30
41
 
31
42
  @dataclass
32
43
  class AuthCredential:
33
- """单个 profile 下保存的凭证。"""
44
+ """单个 profile 下保存的凭证。
34
45
 
35
- api_key: str
46
+ OAuth flow 用 access_token/refresh_token/expires_at;
47
+ 老 flow 用 api_key(向后兼容)。
48
+ """
49
+
50
+ api_key: Optional[str] = None
51
+ access_token: Optional[str] = None
52
+ refresh_token: Optional[str] = None
53
+ expires_at: Optional[int] = None # epoch seconds
54
+ client_id: Optional[str] = None # DCR 拿到的, 30 天 TTL, 过期需重新注册
36
55
  user_id: Optional[str] = None
37
56
  nickname: Optional[str] = None
38
57
  email: Optional[str] = None
39
58
  subscription_tier: Optional[str] = None
40
59
 
41
60
  def to_dict(self) -> dict[str, str]:
42
- d = {"api_key": self.api_key}
61
+ d: dict[str, str] = {}
62
+ if self.api_key:
63
+ d["api_key"] = self.api_key
64
+ if self.access_token:
65
+ d["access_token"] = self.access_token
66
+ if self.refresh_token:
67
+ d["refresh_token"] = self.refresh_token
68
+ if self.expires_at:
69
+ d["expires_at"] = str(self.expires_at)
70
+ if self.client_id:
71
+ d["client_id"] = self.client_id
43
72
  if self.user_id:
44
73
  d["user_id"] = str(self.user_id)
45
74
  if self.nickname:
@@ -66,16 +95,28 @@ def _load_parser() -> configparser.ConfigParser:
66
95
 
67
96
 
68
97
  def load_credential(profile: str = DEFAULT_PROFILE) -> Optional[AuthCredential]:
69
- """读取指定 profile 的凭证;不存在或缺 api_key 时返回 None。"""
98
+ """读取指定 profile 的凭证;不存在或两类 token 都缺时返回 None。"""
70
99
  parser = _load_parser()
71
100
  if not parser.has_section(profile):
72
101
  return None
73
102
  section = parser[profile]
74
103
  api_key = section.get("api_key")
75
- if not api_key:
104
+ access_token = section.get("access_token")
105
+ if not api_key and not access_token:
76
106
  return None
107
+ expires_at_raw = section.get("expires_at")
108
+ expires_at: Optional[int] = None
109
+ if expires_at_raw:
110
+ try:
111
+ expires_at = int(expires_at_raw)
112
+ except ValueError:
113
+ expires_at = None
77
114
  return AuthCredential(
78
115
  api_key=api_key,
116
+ access_token=access_token,
117
+ refresh_token=section.get("refresh_token"),
118
+ expires_at=expires_at,
119
+ client_id=section.get("client_id"),
79
120
  user_id=section.get("user_id"),
80
121
  nickname=section.get("nickname"),
81
122
  email=section.get("email"),
@@ -1,23 +1,25 @@
1
1
  """Reportify CLI authentication commands.
2
2
 
3
- 实现 OAuth 2.0 Device Authorization Grant (RFC 8628) 客户端:
3
+ 实现 OAuth 2.1 Device Authorization Grant (RFC 8628) 客户端:
4
4
 
5
- reportify-cli auth login # 跑 device flow, api_key, 写本地配置
5
+ reportify-cli auth login # 跑 device flow, 保存 JWT, 写本地配置
6
6
  reportify-cli auth logout # 删除本地配置
7
7
  reportify-cli auth status # 显示当前登录状态
8
8
 
9
- 后端契约:
10
- POST {base}/v1/cli-oauth/device/code (form: client_id, scope?)
11
- POST {base}/v1/cli-oauth/device/token (form: grant_type, device_code, client_id)
12
- POST {base}/v1/api-keys/ensure (json: {client_id, source}, Bearer access_token)
9
+ 后端契约(新 oauth_server 端点):
10
+ POST {base}/v1/oauth/device/code (form: client_id, resource, scope?)
11
+ POST {base}/v1/oauth/token (form: grant_type, device_code, client_id, resource)
12
+ POST {base}/v1/oauth/token (form: grant_type=refresh_token, refresh_token, client_id, resource)
13
+
14
+ 返回的 access_token 是 **JWT**(aud=https://api.reportify.cn),客户端把它当
15
+ Bearer 直接发给主 API;主 API 的 TokenMiddleware 会本地验签校验 audience。
13
16
  """
14
17
 
15
18
  from __future__ import annotations
16
19
 
17
- import sys
18
20
  import time
19
21
  import webbrowser
20
- from typing import Annotated, Optional
22
+ from typing import Annotated
21
23
 
22
24
  import httpx
23
25
  import typer
@@ -32,14 +34,15 @@ from src.settings import get_api_base_url
32
34
 
33
35
  app = typer.Typer(help="Authentication", rich_markup_mode="rich")
34
36
 
35
- CLIENT_ID = "reportify-cli"
36
- SCOPE = "profile api_key"
37
- GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
37
+ CLIENT_NAME = "Reportify CLI"
38
+ RESOURCE = "https://api.reportify.cn"
39
+ SCOPE = "api"
40
+ GRANT_TYPE_DEVICE = "urn:ietf:params:oauth:grant-type:device_code"
41
+ GRANT_TYPE_REFRESH = "refresh_token"
38
42
 
39
- # 端点路径(统一 /v1 前缀,与 SDK 同源)
40
- ENDPOINT_DEVICE_CODE = "/v1/cli-oauth/device/code"
41
- ENDPOINT_DEVICE_TOKEN = "/v1/cli-oauth/device/token"
42
- ENDPOINT_API_KEY_ENSURE = "/v1/api-keys/ensure"
43
+ ENDPOINT_REGISTER = "/v1/oauth/register"
44
+ ENDPOINT_DEVICE_CODE = "/v1/oauth/device/code"
45
+ ENDPOINT_TOKEN = "/v1/oauth/token"
43
46
 
44
47
 
45
48
  # ------------------------------------------------------------------ #
@@ -53,16 +56,11 @@ def _err(msg: str, exit_code: int = 1) -> None:
53
56
 
54
57
 
55
58
  def _oauth_error(response: httpx.Response) -> tuple[str, str]:
56
- """Extract RFC 8628 OAuth error from a 4xx response.
57
-
58
- Server returns ``{"detail": {"error": ..., "error_description": ...}}``.
59
- """
59
+ """Extract OAuth error from a 4xx response."""
60
60
  try:
61
61
  body = response.json()
62
62
  except Exception:
63
63
  return ("unknown_error", response.text or "")
64
-
65
- # FastAPI HTTPException 的 detail 嵌在 'detail' 里
66
64
  detail = body.get("detail", body)
67
65
  if isinstance(detail, dict):
68
66
  return (
@@ -72,10 +70,29 @@ def _oauth_error(response: httpx.Response) -> tuple[str, str]:
72
70
  return ("unknown_error", str(detail))
73
71
 
74
72
 
75
- def _request_device_code(client: httpx.Client) -> dict:
73
+ def _register_client(client: httpx.Client) -> str:
74
+ """RFC 7591 Dynamic Client Registration —— 注册本地 CLI 实例, 返回 client_id。"""
75
+ resp = client.post(
76
+ ENDPOINT_REGISTER,
77
+ json={
78
+ "client_name": CLIENT_NAME,
79
+ "redirect_uris": [], # device flow 不需要
80
+ "grant_types": [GRANT_TYPE_DEVICE, GRANT_TYPE_REFRESH],
81
+ "response_types": [],
82
+ "token_endpoint_auth_method": "none",
83
+ "scope": SCOPE,
84
+ },
85
+ )
86
+ if resp.status_code != 200:
87
+ err, desc = _oauth_error(resp)
88
+ _err(f"failed to register client ({err}): {desc}")
89
+ return resp.json()["client_id"]
90
+
91
+
92
+ def _request_device_code(client: httpx.Client, client_id: str) -> dict:
76
93
  resp = client.post(
77
94
  ENDPOINT_DEVICE_CODE,
78
- data={"client_id": CLIENT_ID, "scope": SCOPE},
95
+ data={"client_id": client_id, "resource": RESOURCE, "scope": SCOPE},
79
96
  )
80
97
  if resp.status_code != 200:
81
98
  err, desc = _oauth_error(resp)
@@ -83,8 +100,10 @@ def _request_device_code(client: httpx.Client) -> dict:
83
100
  return resp.json()
84
101
 
85
102
 
86
- def _poll_token(client: httpx.Client, device_code: str, interval: int, expires_in: int) -> dict:
87
- """轮询 /device/token,直到拿到 access_token 或超时 / 用户拒绝。"""
103
+ def _poll_token(
104
+ client: httpx.Client, device_code: str, client_id: str, interval: int, expires_in: int
105
+ ) -> dict:
106
+ """轮询 /v1/oauth/token (grant_type=device_code),直到拿到 JWT 或超时/被拒。"""
88
107
  deadline = time.monotonic() + expires_in
89
108
  current_interval = max(interval, 1)
90
109
  typer.echo("Waiting for authorization", nl=False)
@@ -97,11 +116,12 @@ def _poll_token(client: httpx.Client, device_code: str, interval: int, expires_i
97
116
  typer.echo(".", nl=False)
98
117
 
99
118
  resp = client.post(
100
- ENDPOINT_DEVICE_TOKEN,
119
+ ENDPOINT_TOKEN,
101
120
  data={
102
- "grant_type": GRANT_TYPE,
121
+ "grant_type": GRANT_TYPE_DEVICE,
103
122
  "device_code": device_code,
104
- "client_id": CLIENT_ID,
123
+ "client_id": client_id,
124
+ "resource": RESOURCE,
105
125
  },
106
126
  )
107
127
 
@@ -118,30 +138,42 @@ def _poll_token(client: httpx.Client, device_code: str, interval: int, expires_i
118
138
  if err == "access_denied":
119
139
  typer.echo("")
120
140
  _err("authorization was denied in the browser")
121
- if err == "expired_token":
141
+ if err in ("expired_token", "invalid_grant"):
122
142
  typer.echo("")
123
143
  _err("device code expired before authorization; please try `auth login` again")
124
144
 
125
- # Any other error → fail loud
126
145
  typer.echo("")
127
146
  _err(f"failed to obtain access token ({err}): {desc}")
128
147
 
129
148
 
130
- def _ensure_api_key(client: httpx.Client, access_token: str) -> dict:
131
- # reportify TokenMiddleware 用 `X-Reportify-Token` 识别 web 登录态 token;
132
- # `Authorization: Bearer` 那个 slot 给长期 api_key 用的。两个 header 不能混。
133
- resp = client.post(
134
- ENDPOINT_API_KEY_ENSURE,
135
- headers={"X-Reportify-Token": access_token},
136
- json={"client_id": CLIENT_ID, "source": "cli"},
149
+ def _credential_from_token_response(token_resp: dict) -> AuthCredential:
150
+ expires_in = int(token_resp.get("expires_in", 3600))
151
+ return AuthCredential(
152
+ access_token=token_resp["access_token"],
153
+ refresh_token=token_resp.get("refresh_token"),
154
+ expires_at=int(time.time()) + expires_in,
137
155
  )
138
- if resp.status_code != 200:
139
- try:
140
- detail = resp.json().get("detail", resp.text)
141
- except Exception:
142
- detail = resp.text
143
- _err(f"failed to obtain api key (HTTP {resp.status_code}): {detail}")
144
- return resp.json()
156
+
157
+
158
+ def refresh_access_token(refresh_token: str, client_id: str) -> AuthCredential:
159
+ """刷新 access_token;返回新的 AuthCredential(含轮转后的 refresh_token)。"""
160
+ base_url = get_api_base_url().rstrip("/")
161
+ with httpx.Client(base_url=base_url, timeout=30.0) as client:
162
+ resp = client.post(
163
+ ENDPOINT_TOKEN,
164
+ data={
165
+ "grant_type": GRANT_TYPE_REFRESH,
166
+ "refresh_token": refresh_token,
167
+ "client_id": client_id,
168
+ "resource": RESOURCE,
169
+ },
170
+ )
171
+ if resp.status_code != 200:
172
+ err, desc = _oauth_error(resp)
173
+ _err(f"refresh failed ({err}): {desc}. Please run `reportify-cli auth login` again.")
174
+ cred = _credential_from_token_response(resp.json())
175
+ cred.client_id = client_id # 保持原 client_id
176
+ return cred
145
177
 
146
178
 
147
179
  # ------------------------------------------------------------------ #
@@ -159,14 +191,23 @@ def login(
159
191
  ),
160
192
  ] = False,
161
193
  ) -> None:
162
- """Authenticate to Reportify via the device authorization flow.
194
+ """Authenticate to Reportify via the OAuth 2.1 device flow.
163
195
 
164
196
  Opens the browser to a verification page. After you approve the request,
165
- the CLI fetches a long-lived API key and stores it in ``~/.reportify/config``.
197
+ the CLI stores a short-lived access token (JWT) + refresh token in
198
+ ``~/.reportify/config``. Subsequent CLI commands use the access token
199
+ automatically; expired tokens are silently refreshed.
166
200
  """
167
201
  base_url = get_api_base_url().rstrip("/")
202
+ # 复用已注册的 client_id(30 天 TTL);不存在则发起 DCR
203
+ existing = load_credential()
204
+ client_id = existing.client_id if existing and existing.client_id else None
205
+
168
206
  with httpx.Client(base_url=base_url, timeout=30.0) as client:
169
- device_resp = _request_device_code(client)
207
+ if not client_id:
208
+ client_id = _register_client(client)
209
+
210
+ device_resp = _request_device_code(client, client_id)
170
211
 
171
212
  verification_uri_complete = device_resp.get("verification_uri_complete")
172
213
  verification_uri = device_resp.get("verification_uri")
@@ -185,28 +226,16 @@ def login(
185
226
  try:
186
227
  webbrowser.open(verification_uri_complete)
187
228
  except Exception:
188
- # 浏览器打开失败不影响主流程,用户可以自己复制链接
189
229
  pass
190
230
 
191
- token_resp = _poll_token(client, device_code, interval, expires_in)
192
- access_token = token_resp["access_token"]
193
- user = token_resp.get("user") or {}
194
-
195
- key_resp = _ensure_api_key(client, access_token)
231
+ token_resp = _poll_token(client, device_code, client_id, interval, expires_in)
196
232
 
197
- credential = AuthCredential(
198
- api_key=key_resp["key"],
199
- user_id=str(user.get("user_id") or ""),
200
- nickname=user.get("nickname") or None,
201
- email=user.get("email") or None,
202
- subscription_tier=user.get("subscription_tier") or None,
203
- )
233
+ credential = _credential_from_token_response(token_resp)
234
+ credential.client_id = client_id
204
235
  path = save_credential(credential)
205
236
 
206
237
  typer.echo("")
207
- who = credential.nickname or credential.email or credential.user_id or "unknown"
208
- tier = credential.subscription_tier or "free"
209
- typer.echo(f"Logged in as {who} (plan: {tier})")
238
+ typer.echo("Logged in successfully")
210
239
  typer.echo(f"Credential saved to {path}")
211
240
 
212
241
 
@@ -214,8 +243,8 @@ def login(
214
243
  def logout() -> None:
215
244
  """Remove the locally stored Reportify credential.
216
245
 
217
- Does **not** revoke the API key on the server side. If you want the key
218
- invalidated, also delete it from your Reportify settings page.
246
+ Does **not** revoke the token on the server side. If you want the token
247
+ invalidated, also revoke it from your Reportify account settings.
219
248
  """
220
249
  removed = delete_credential()
221
250
  if removed:
@@ -236,7 +265,9 @@ def status() -> None:
236
265
  typer.echo("Not logged in. Run `reportify-cli auth login`.")
237
266
  raise typer.Exit(1)
238
267
 
239
- who = cred.nickname or cred.email or cred.user_id or "unknown"
240
- tier = cred.subscription_tier or "unknown"
241
- typer.echo(f"Logged in as {who} (plan: {tier})")
268
+ if cred.access_token:
269
+ remaining = (cred.expires_at or 0) - int(time.time())
270
+ typer.echo(f"Logged in (OAuth, token expires in {max(remaining, 0)}s)")
271
+ elif cred.api_key:
272
+ typer.echo("Logged in (legacy API Key)")
242
273
  typer.echo(f"API base: {get_api_base_url()}")
@@ -0,0 +1,87 @@
1
+ """Settings and configuration for Reportify API CLI."""
2
+
3
+ import os
4
+ import sys
5
+ import time
6
+
7
+ # Try to load .env file if python-dotenv is available
8
+ try:
9
+ from dotenv import load_dotenv
10
+
11
+ load_dotenv()
12
+ except ImportError:
13
+ # python-dotenv not installed, skip loading .env file
14
+ pass
15
+
16
+ from src.auth_config import load_credential, save_credential
17
+
18
+ # Default API base URL. SDK 默认也是这个;CLI 自己的 auth 命令也走它。
19
+ DEFAULT_API_BASE_URL = "https://api.reportify.cn"
20
+
21
+ # 距过期多少秒以内强制刷新(防止用户长时间 CLI 跑批时半路过期)
22
+ _REFRESH_LEEWAY_SEC = 60
23
+
24
+
25
+ def get_api_base_url() -> str:
26
+ """API 基础 URL。优先级: REPORTIFY_BASE_URL > 默认值。
27
+
28
+ 与 reportify-sdk 的 Reportify 客户端保持一致,便于 dev / staging 切换。
29
+ """
30
+ return os.getenv("REPORTIFY_BASE_URL") or DEFAULT_API_BASE_URL
31
+
32
+
33
+ def _is_token_fresh(expires_at: int | None) -> bool:
34
+ if not expires_at:
35
+ return False
36
+ return expires_at - int(time.time()) > _REFRESH_LEEWAY_SEC
37
+
38
+
39
+ def get_api_key() -> str:
40
+ """Get a bearer credential, in priority order:
41
+
42
+ 1. ``REPORTIFY_API_KEY`` environment variable(可以是 api_key 或 JWT)
43
+ 2. 本地配置文件中未过期的 OAuth access_token (JWT)
44
+ 3. OAuth refresh_token 自动续期后的新 access_token
45
+ 4. 本地配置文件中的老 api_key(向后兼容)
46
+
47
+ 主 API 的 TokenMiddleware 同时接受这两种凭证形态。
48
+
49
+ Raises:
50
+ SystemExit: If none of the above is available.
51
+ """
52
+ env_key = os.getenv("REPORTIFY_API_KEY")
53
+ if env_key:
54
+ return env_key
55
+
56
+ cred = load_credential()
57
+ if cred:
58
+ # 1. 未过期的 JWT 直接用
59
+ if cred.access_token and _is_token_fresh(cred.expires_at):
60
+ return cred.access_token
61
+
62
+ # 2. JWT 过期但有 refresh_token,自动续期
63
+ if cred.refresh_token and cred.client_id:
64
+ # 延迟 import 避开 circular(commands/auth.py 反过来 import settings)
65
+ from src.commands.auth import refresh_access_token
66
+
67
+ refreshed = refresh_access_token(cred.refresh_token, cred.client_id)
68
+ cred.access_token = refreshed.access_token
69
+ cred.refresh_token = refreshed.refresh_token or cred.refresh_token
70
+ cred.expires_at = refreshed.expires_at
71
+ save_credential(cred)
72
+ return cred.access_token
73
+
74
+ # 3. 老 api_key fallback
75
+ if cred.api_key:
76
+ return cred.api_key
77
+
78
+ print("Error: no Reportify credential found.", file=sys.stderr)
79
+ print(
80
+ " - Run `reportify-cli auth login` to authenticate, or",
81
+ file=sys.stderr,
82
+ )
83
+ print(
84
+ " - Set the REPORTIFY_API_KEY environment variable manually.",
85
+ file=sys.stderr,
86
+ )
87
+ sys.exit(1)
@@ -66,30 +66,31 @@ def _device_code_payload() -> dict:
66
66
  }
67
67
 
68
68
 
69
- def _token_payload() -> dict:
69
+ def _register_payload() -> dict:
70
+ """RFC 7591 DCR 响应(CLI login 首次调用时拿 client_id)。"""
70
71
  return {
71
- "access_token": "fake-access-token",
72
- "token_type": "Bearer",
73
- "expires_in": 3600,
74
- "scope": "profile api_key",
75
- "user": {
76
- "user_id": "12345",
77
- "nickname": "Foo",
78
- "email": "foo@bar.com",
79
- "subscription_tier": "plus",
80
- },
81
- "entitlements": ["official_skills"],
72
+ "client_id": "dyn_cli_test_123",
73
+ "client_id_issued_at": 0,
74
+ "client_name": "Reportify CLI",
75
+ "redirect_uris": [],
76
+ "grant_types": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"],
77
+ "response_types": [],
78
+ "token_endpoint_auth_method": "none",
79
+ "scope": "api",
82
80
  }
83
81
 
84
82
 
85
- def _api_key_payload() -> dict:
83
+ def _token_payload() -> dict:
84
+ """oauth_server /v1/oauth/token (grant_type=device_code) 的响应。
85
+
86
+ JWT 格式不重要(CLI 不解码),随便给个 header.payload.signature。
87
+ """
86
88
  return {
87
- "id": 99,
88
- "key": "raw-api-key-12345",
89
- "name": "Reportify CLI",
90
- "client_id": "reportify-cli",
91
- "source": "cli",
92
- "created_at": 0,
89
+ "access_token": "header.payload.signature",
90
+ "token_type": "Bearer",
91
+ "expires_in": 3600,
92
+ "refresh_token": "refresh-token-abc",
93
+ "scope": "api",
93
94
  }
94
95
 
95
96
 
@@ -122,14 +123,25 @@ class TestStatus:
122
123
  assert result.exit_code == 1
123
124
  assert "Not logged in" in result.stdout
124
125
 
125
- def test_logged_in_exits_zero(self, tmp_config):
126
+ def test_logged_in_with_legacy_api_key(self, tmp_config):
127
+ save_credential(AuthCredential(api_key="x"))
128
+ result = runner.invoke(app, ["status"])
129
+ assert result.exit_code == 0
130
+ assert "legacy API Key" in result.stdout
131
+
132
+ def test_logged_in_with_oauth_token(self, tmp_config):
133
+ import time as _time
134
+
126
135
  save_credential(
127
- AuthCredential(api_key="x", nickname="Foo", subscription_tier="plus")
136
+ AuthCredential(
137
+ access_token="header.payload.signature",
138
+ refresh_token="rt",
139
+ expires_at=int(_time.time()) + 3600,
140
+ )
128
141
  )
129
142
  result = runner.invoke(app, ["status"])
130
143
  assert result.exit_code == 0
131
- assert "Foo" in result.stdout
132
- assert "plus" in result.stdout
144
+ assert "OAuth" in result.stdout
133
145
 
134
146
 
135
147
  class TestLogout:
@@ -154,46 +166,66 @@ class TestLoginHappyPath:
154
166
  self, tmp_config, fast_sleep, patch_client
155
167
  ):
156
168
  patch_client.post.side_effect = [
157
- _ok(_device_code_payload()), # /device/code
158
- _ok(_token_payload()), # /device/token (approved)
159
- _ok(_api_key_payload()), # /api-keys/ensure
169
+ _ok(_register_payload()), # /v1/oauth/register (DCR)
170
+ _ok(_device_code_payload()), # /v1/oauth/device/code
171
+ _ok(_token_payload()), # /v1/oauth/token (approved)
160
172
  ]
161
173
 
162
174
  result = runner.invoke(app, ["login", "--no-browser"])
163
175
  assert result.exit_code == 0, result.stdout
164
176
  assert "WDJB-MJHT" in result.stdout
165
- assert "Logged in as Foo" in result.stdout
177
+ assert "Logged in successfully" in result.stdout
166
178
 
167
179
  cred = load_credential()
168
180
  assert cred is not None
169
- assert cred.api_key == "raw-api-key-12345"
170
- assert cred.user_id == "12345"
171
- assert cred.nickname == "Foo"
172
- assert cred.email == "foo@bar.com"
173
- assert cred.subscription_tier == "plus"
181
+ assert cred.client_id == "dyn_cli_test_123"
182
+ assert cred.access_token == "header.payload.signature"
183
+ assert cred.refresh_token == "refresh-token-abc"
184
+ assert cred.expires_at is not None and cred.expires_at > 0
185
+
186
+ def test_reuses_existing_client_id_skipping_dcr(
187
+ self, tmp_config, fast_sleep, patch_client
188
+ ):
189
+ """有本地 client_id 时不应重复 DCR,直接进 device flow。"""
190
+ # 模拟用户之前登录过(有 client_id + 旧 token)
191
+ save_credential(
192
+ AuthCredential(
193
+ client_id="cached_client",
194
+ access_token="old.access.token",
195
+ refresh_token="old-refresh",
196
+ expires_at=0, # 过期
197
+ )
198
+ )
199
+ patch_client.post.side_effect = [
200
+ _ok(_device_code_payload()), # 直接 device flow, 跳过 DCR
201
+ _ok(_token_payload()),
202
+ ]
203
+ result = runner.invoke(app, ["login", "--no-browser"])
204
+ assert result.exit_code == 0, result.stdout
205
+ assert load_credential().client_id == "cached_client"
174
206
 
175
207
  def test_authorization_pending_then_approved(
176
208
  self, tmp_config, fast_sleep, patch_client
177
209
  ):
178
210
  patch_client.post.side_effect = [
211
+ _ok(_register_payload()),
179
212
  _ok(_device_code_payload()),
180
213
  _oauth_err("authorization_pending"),
181
214
  _oauth_err("authorization_pending"),
182
215
  _ok(_token_payload()),
183
- _ok(_api_key_payload()),
184
216
  ]
185
217
  result = runner.invoke(app, ["login", "--no-browser"])
186
218
  assert result.exit_code == 0, result.stdout
187
- assert load_credential().api_key == "raw-api-key-12345"
219
+ assert load_credential().access_token == "header.payload.signature"
188
220
 
189
221
  def test_slow_down_increments_interval_but_succeeds(
190
222
  self, tmp_config, fast_sleep, patch_client
191
223
  ):
192
224
  patch_client.post.side_effect = [
225
+ _ok(_register_payload()),
193
226
  _ok(_device_code_payload()),
194
227
  _oauth_err("slow_down"),
195
228
  _ok(_token_payload()),
196
- _ok(_api_key_payload()),
197
229
  ]
198
230
  result = runner.invoke(app, ["login", "--no-browser"])
199
231
  assert result.exit_code == 0, result.stdout
@@ -205,6 +237,7 @@ class TestLoginHappyPath:
205
237
  class TestLoginFailurePaths:
206
238
  def test_access_denied_aborts(self, tmp_config, fast_sleep, patch_client):
207
239
  patch_client.post.side_effect = [
240
+ _ok(_register_payload()),
208
241
  _ok(_device_code_payload()),
209
242
  _oauth_err("access_denied"),
210
243
  ]
@@ -215,6 +248,7 @@ class TestLoginFailurePaths:
215
248
 
216
249
  def test_expired_token_aborts(self, tmp_config, fast_sleep, patch_client):
217
250
  patch_client.post.side_effect = [
251
+ _ok(_register_payload()),
218
252
  _ok(_device_code_payload()),
219
253
  _oauth_err("expired_token"),
220
254
  ]
@@ -222,34 +256,45 @@ class TestLoginFailurePaths:
222
256
  assert result.exit_code == 1
223
257
  assert "expired" in _all_output(result)
224
258
 
259
+ def test_register_failure_aborts(self, tmp_config, patch_client):
260
+ """DCR 失败时直接 abort, 不进入 device flow。"""
261
+ patch_client.post.side_effect = [
262
+ _oauth_err("invalid_redirect_uri", "bad uri"),
263
+ ]
264
+ result = runner.invoke(app, ["login", "--no-browser"])
265
+ assert result.exit_code == 1
266
+ assert "register" in _all_output(result).lower()
267
+
225
268
  def test_device_code_endpoint_failure(self, tmp_config, patch_client):
226
269
  patch_client.post.side_effect = [
270
+ _ok(_register_payload()),
227
271
  _oauth_err("invalid_client", "unknown client_id"),
228
272
  ]
229
273
  result = runner.invoke(app, ["login", "--no-browser"])
230
274
  assert result.exit_code == 1
231
275
  assert "invalid_client" in _all_output(result)
232
276
 
233
- def test_api_key_ensure_failure(
277
+ def test_invalid_grant_treated_as_expired(
234
278
  self, tmp_config, fast_sleep, patch_client
235
279
  ):
280
+ """新 flow: invalid_grant 视同 expired_token,提示用户重跑 login。"""
236
281
  patch_client.post.side_effect = [
282
+ _ok(_register_payload()),
237
283
  _ok(_device_code_payload()),
238
- _ok(_token_payload()),
239
- httpx.Response(status_code=500, json={"detail": "server error"}),
284
+ _oauth_err("invalid_grant", "device_code already used"),
240
285
  ]
241
286
  result = runner.invoke(app, ["login", "--no-browser"])
242
287
  assert result.exit_code == 1
243
- assert "api key" in _all_output(result).lower()
244
- assert load_credential() is None
288
+ assert "expired" in _all_output(result).lower()
245
289
 
246
290
  def test_unknown_oauth_error_aborts(
247
291
  self, tmp_config, fast_sleep, patch_client
248
292
  ):
249
293
  patch_client.post.side_effect = [
294
+ _ok(_register_payload()),
250
295
  _ok(_device_code_payload()),
251
- _oauth_err("invalid_grant", "device_code already used"),
296
+ _oauth_err("invalid_target", "resource is not allowed"),
252
297
  ]
253
298
  result = runner.invoke(app, ["login", "--no-browser"])
254
299
  assert result.exit_code == 1
255
- assert "invalid_grant" in _all_output(result)
300
+ assert "invalid_target" in _all_output(result)
@@ -426,7 +426,7 @@ wheels = [
426
426
 
427
427
  [[package]]
428
428
  name = "reportify-cli"
429
- version = "0.1.44"
429
+ version = "0.1.46"
430
430
  source = { editable = "." }
431
431
  dependencies = [
432
432
  { name = "pandas" },
@@ -445,7 +445,7 @@ dev = [
445
445
  requires-dist = [
446
446
  { name = "pandas", specifier = ">=2.0.0" },
447
447
  { name = "python-dotenv", specifier = ">=1.2.1" },
448
- { name = "reportify-sdk", specifier = ">=0.3.46" },
448
+ { name = "reportify-sdk", specifier = ">=0.3.49" },
449
449
  { name = "tabulate", specifier = ">=0.9.0" },
450
450
  { name = "typer", specifier = ">=0.21.1" },
451
451
  ]
@@ -1,55 +0,0 @@
1
- """Settings and configuration for Reportify API CLI."""
2
-
3
- import os
4
- import sys
5
-
6
- # Try to load .env file if python-dotenv is available
7
- try:
8
- from dotenv import load_dotenv
9
-
10
- load_dotenv()
11
- except ImportError:
12
- # python-dotenv not installed, skip loading .env file
13
- pass
14
-
15
- from src.auth_config import load_credential
16
-
17
- # Default API base URL. SDK 默认也是这个;CLI 自己的 auth 命令也走它。
18
- DEFAULT_API_BASE_URL = "https://api.reportify.cn"
19
-
20
-
21
- def get_api_base_url() -> str:
22
- """API 基础 URL。优先级: REPORTIFY_BASE_URL > 默认值。
23
-
24
- 与 reportify-sdk 的 Reportify 客户端保持一致,便于 dev / staging 切换。
25
- """
26
- return os.getenv("REPORTIFY_BASE_URL") or DEFAULT_API_BASE_URL
27
-
28
-
29
- def get_api_key() -> str:
30
- """Get API key, in priority order:
31
-
32
- 1. ``REPORTIFY_API_KEY`` environment variable
33
- 2. ``~/.reportify/config`` [default] profile (written by ``reportify-cli auth login``)
34
-
35
- Raises:
36
- SystemExit: If neither source has a key.
37
- """
38
- api_key = os.getenv("REPORTIFY_API_KEY")
39
- if api_key:
40
- return api_key
41
-
42
- cred = load_credential()
43
- if cred and cred.api_key:
44
- return cred.api_key
45
-
46
- print("Error: no Reportify API key found.", file=sys.stderr)
47
- print(
48
- " - Run `reportify-cli auth login` to authenticate, or",
49
- file=sys.stderr,
50
- )
51
- print(
52
- " - Set the REPORTIFY_API_KEY environment variable manually.",
53
- file=sys.stderr,
54
- )
55
- sys.exit(1)
File without changes
File without changes