reportify-cli 0.1.47__tar.gz → 0.1.49__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.47 → reportify_cli-0.1.49}/PKG-INFO +1 -1
  2. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/pyproject.toml +1 -1
  3. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/auth_config.py +49 -8
  4. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/commands/auth.py +78 -67
  5. reportify_cli-0.1.49/src/settings.py +87 -0
  6. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/tests/test_commands/test_auth_commands.py +87 -42
  7. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/uv.lock +2 -2
  8. reportify_cli-0.1.47/src/settings.py +0 -55
  9. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/.gitignore +0 -0
  10. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/Makefile +0 -0
  11. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/README.md +0 -0
  12. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/scripts/README.md +0 -0
  13. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/scripts/bump_version.sh +0 -0
  14. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/scripts/publish.sh +0 -0
  15. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/skills/reportify-agent/SKILL.md +0 -0
  16. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/skills/reportify-ai/SKILL.md +0 -0
  17. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/skills/reportify-ai/references/API_REFERENCE.md +0 -0
  18. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/skills/reportify-ai/references/COMMANDS.md +0 -0
  19. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/__init__.py +0 -0
  20. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/client.py +0 -0
  21. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/commands/__init__.py +0 -0
  22. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/commands/agent.py +0 -0
  23. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/commands/channels.py +0 -0
  24. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/commands/concepts.py +0 -0
  25. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/commands/docs.py +0 -0
  26. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/commands/following.py +0 -0
  27. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/commands/kb.py +0 -0
  28. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/commands/macro.py +0 -0
  29. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/commands/quant.py +0 -0
  30. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/commands/search.py +0 -0
  31. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/commands/stock.py +0 -0
  32. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/commands/timeline.py +0 -0
  33. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/commands/user.py +0 -0
  34. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/main.py +0 -0
  35. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/output.py +0 -0
  36. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/params.py +0 -0
  37. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/src/utils.py +0 -0
  38. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/tests/__init__.py +0 -0
  39. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/tests/integration/test_docs_integration.py +0 -0
  40. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/tests/integration/test_stock_integration.py +0 -0
  41. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/tests/test_commands/__init__.py +0 -0
  42. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/tests/test_commands/test_auth_config.py +0 -0
  43. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/tests/test_commands/test_docs.py +0 -0
  44. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/tests/test_commands/test_quant.py +0 -0
  45. {reportify_cli-0.1.47 → reportify_cli-0.1.49}/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.47
3
+ Version: 0.1.49
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.47"
3
+ version = "0.1.49"
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,19 @@ from src.settings import get_api_base_url
32
34
 
33
35
  app = typer.Typer(help="Authentication", rich_markup_mode="rich")
34
36
 
37
+ # Reportify CLI 是 first-party 客户端, 用预注册的稳定 client_id (后端配置在
38
+ # OAUTH_FIRSTPARTY_CLIENTS 里), 不走 DCR. 这样可以拿到 api 域的 token (DCR
39
+ # 注册的客户端默认只能拿 mcp 域).
40
+ #
41
+ # Public client (PKCE 防 code 截获), client_id 公开不构成安全风险.
35
42
  CLIENT_ID = "reportify-cli"
36
- SCOPE = "profile api_key"
37
- GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
43
+ RESOURCE = "https://api.reportify.cn"
44
+ SCOPE = "api"
45
+ GRANT_TYPE_DEVICE = "urn:ietf:params:oauth:grant-type:device_code"
46
+ GRANT_TYPE_REFRESH = "refresh_token"
38
47
 
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 = "/api-keys/ensure"
48
+ ENDPOINT_DEVICE_CODE = "/v1/oauth/device/code"
49
+ ENDPOINT_TOKEN = "/v1/oauth/token"
43
50
 
44
51
 
45
52
  # ------------------------------------------------------------------ #
@@ -53,16 +60,11 @@ def _err(msg: str, exit_code: int = 1) -> None:
53
60
 
54
61
 
55
62
  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
- """
63
+ """Extract OAuth error from a 4xx response."""
60
64
  try:
61
65
  body = response.json()
62
66
  except Exception:
63
67
  return ("unknown_error", response.text or "")
64
-
65
- # FastAPI HTTPException 的 detail 嵌在 'detail' 里
66
68
  detail = body.get("detail", body)
67
69
  if isinstance(detail, dict):
68
70
  return (
@@ -72,10 +74,10 @@ def _oauth_error(response: httpx.Response) -> tuple[str, str]:
72
74
  return ("unknown_error", str(detail))
73
75
 
74
76
 
75
- def _request_device_code(client: httpx.Client) -> dict:
77
+ def _request_device_code(client: httpx.Client, client_id: str) -> dict:
76
78
  resp = client.post(
77
79
  ENDPOINT_DEVICE_CODE,
78
- data={"client_id": CLIENT_ID, "scope": SCOPE},
80
+ data={"client_id": client_id, "resource": RESOURCE, "scope": SCOPE},
79
81
  )
80
82
  if resp.status_code != 200:
81
83
  err, desc = _oauth_error(resp)
@@ -83,8 +85,10 @@ def _request_device_code(client: httpx.Client) -> dict:
83
85
  return resp.json()
84
86
 
85
87
 
86
- def _poll_token(client: httpx.Client, device_code: str, interval: int, expires_in: int) -> dict:
87
- """轮询 /device/token,直到拿到 access_token 或超时 / 用户拒绝。"""
88
+ def _poll_token(
89
+ client: httpx.Client, device_code: str, client_id: str, interval: int, expires_in: int
90
+ ) -> dict:
91
+ """轮询 /v1/oauth/token (grant_type=device_code),直到拿到 JWT 或超时/被拒。"""
88
92
  deadline = time.monotonic() + expires_in
89
93
  current_interval = max(interval, 1)
90
94
  typer.echo("Waiting for authorization", nl=False)
@@ -97,11 +101,12 @@ def _poll_token(client: httpx.Client, device_code: str, interval: int, expires_i
97
101
  typer.echo(".", nl=False)
98
102
 
99
103
  resp = client.post(
100
- ENDPOINT_DEVICE_TOKEN,
104
+ ENDPOINT_TOKEN,
101
105
  data={
102
- "grant_type": GRANT_TYPE,
106
+ "grant_type": GRANT_TYPE_DEVICE,
103
107
  "device_code": device_code,
104
- "client_id": CLIENT_ID,
108
+ "client_id": client_id,
109
+ "resource": RESOURCE,
105
110
  },
106
111
  )
107
112
 
@@ -118,30 +123,42 @@ def _poll_token(client: httpx.Client, device_code: str, interval: int, expires_i
118
123
  if err == "access_denied":
119
124
  typer.echo("")
120
125
  _err("authorization was denied in the browser")
121
- if err == "expired_token":
126
+ if err in ("expired_token", "invalid_grant"):
122
127
  typer.echo("")
123
128
  _err("device code expired before authorization; please try `auth login` again")
124
129
 
125
- # Any other error → fail loud
126
130
  typer.echo("")
127
131
  _err(f"failed to obtain access token ({err}): {desc}")
128
132
 
129
133
 
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"},
134
+ def _credential_from_token_response(token_resp: dict) -> AuthCredential:
135
+ expires_in = int(token_resp.get("expires_in", 3600))
136
+ return AuthCredential(
137
+ access_token=token_resp["access_token"],
138
+ refresh_token=token_resp.get("refresh_token"),
139
+ expires_at=int(time.time()) + expires_in,
137
140
  )
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()
141
+
142
+
143
+ def refresh_access_token(refresh_token: str, client_id: str) -> AuthCredential:
144
+ """刷新 access_token;返回新的 AuthCredential(含轮转后的 refresh_token)。"""
145
+ base_url = get_api_base_url().rstrip("/")
146
+ with httpx.Client(base_url=base_url, timeout=30.0) as client:
147
+ resp = client.post(
148
+ ENDPOINT_TOKEN,
149
+ data={
150
+ "grant_type": GRANT_TYPE_REFRESH,
151
+ "refresh_token": refresh_token,
152
+ "client_id": client_id,
153
+ "resource": RESOURCE,
154
+ },
155
+ )
156
+ if resp.status_code != 200:
157
+ err, desc = _oauth_error(resp)
158
+ _err(f"refresh failed ({err}): {desc}. Please run `reportify-cli auth login` again.")
159
+ cred = _credential_from_token_response(resp.json())
160
+ cred.client_id = client_id # 保持原 client_id
161
+ return cred
145
162
 
146
163
 
147
164
  # ------------------------------------------------------------------ #
@@ -159,14 +176,18 @@ def login(
159
176
  ),
160
177
  ] = False,
161
178
  ) -> None:
162
- """Authenticate to Reportify via the device authorization flow.
179
+ """Authenticate to Reportify via the OAuth 2.1 device flow.
163
180
 
164
181
  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``.
182
+ the CLI stores a short-lived access token (JWT) + refresh token in
183
+ ``~/.reportify/config``. Subsequent CLI commands use the access token
184
+ automatically; expired tokens are silently refreshed.
166
185
  """
167
186
  base_url = get_api_base_url().rstrip("/")
187
+ client_id = CLIENT_ID # first-party 预注册, 不走 DCR
188
+
168
189
  with httpx.Client(base_url=base_url, timeout=30.0) as client:
169
- device_resp = _request_device_code(client)
190
+ device_resp = _request_device_code(client, client_id)
170
191
 
171
192
  verification_uri_complete = device_resp.get("verification_uri_complete")
172
193
  verification_uri = device_resp.get("verification_uri")
@@ -185,28 +206,16 @@ def login(
185
206
  try:
186
207
  webbrowser.open(verification_uri_complete)
187
208
  except Exception:
188
- # 浏览器打开失败不影响主流程,用户可以自己复制链接
189
209
  pass
190
210
 
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 {}
211
+ token_resp = _poll_token(client, device_code, client_id, interval, expires_in)
194
212
 
195
- key_resp = _ensure_api_key(client, access_token)
196
-
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
- )
213
+ credential = _credential_from_token_response(token_resp)
214
+ credential.client_id = client_id
204
215
  path = save_credential(credential)
205
216
 
206
217
  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})")
218
+ typer.echo("Logged in successfully")
210
219
  typer.echo(f"Credential saved to {path}")
211
220
 
212
221
 
@@ -214,8 +223,8 @@ def login(
214
223
  def logout() -> None:
215
224
  """Remove the locally stored Reportify credential.
216
225
 
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.
226
+ Does **not** revoke the token on the server side. If you want the token
227
+ invalidated, also revoke it from your Reportify account settings.
219
228
  """
220
229
  removed = delete_credential()
221
230
  if removed:
@@ -236,7 +245,9 @@ def status() -> None:
236
245
  typer.echo("Not logged in. Run `reportify-cli auth login`.")
237
246
  raise typer.Exit(1)
238
247
 
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})")
248
+ if cred.access_token:
249
+ remaining = (cred.expires_at or 0) - int(time.time())
250
+ typer.echo(f"Logged in (OAuth, token expires in {max(remaining, 0)}s)")
251
+ elif cred.api_key:
252
+ typer.echo("Logged in (legacy API Key)")
242
253
  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