@qingflow-tech/qingflow-cli 1.1.43-beta.5 → 1.1.44-beta.1
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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/reference/builder/30-schema-fields.md +4 -0
- package/src/qingflow_mcp/auth_provider.py +522 -0
- package/src/qingflow_mcp/builder_facade/app_form.py +254 -50
- package/src/qingflow_mcp/cli/commands/auth.py +125 -23
- package/src/qingflow_mcp/cli/context.py +13 -2
- package/src/qingflow_mcp/cli/main.py +9 -1
- package/src/qingflow_mcp/cli/oauth_callback.py +140 -0
- package/src/qingflow_mcp/cli/oauth_client.py +276 -0
- package/src/qingflow_mcp/cloud_auth.py +20 -0
- package/src/qingflow_mcp/cloud_oauth_proxy.py +1 -0
- package/src/qingflow_mcp/config.py +12 -0
- package/src/qingflow_mcp/oauth_profile_store.py +264 -0
- package/src/qingflow_mcp/oauth_token_store.py +210 -0
- package/src/qingflow_mcp/server_cloud.py +21 -1
- package/src/qingflow_mcp/tools/auth_tools.py +163 -1
- package/src/qingflow_mcp/tools/base.py +41 -2
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Qingflow CLI 是轻流面向开发者和 AI Agent 的命令行工具。它把轻
|
|
|
7
7
|
## 安装
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm install @qingflow-tech/qingflow-cli@1.1.
|
|
10
|
+
npm install @qingflow-tech/qingflow-cli@1.1.44-beta.1
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
推荐从官网复制这一条一键安装 Qingflow CLI:
|
|
@@ -21,7 +21,7 @@ npx qingflow-cli@latest install
|
|
|
21
21
|
## 运行
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.
|
|
24
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.44-beta.1 qingflow
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
全局安装后可以直接运行:
|
|
@@ -62,7 +62,7 @@ qingflow-skills install --agent codex --scope user
|
|
|
62
62
|
如果你通过一次性 `npx -p` 执行 skills 安装,建议加 `--copy`,避免 symlink 指向 npm 临时执行缓存:
|
|
63
63
|
|
|
64
64
|
```bash
|
|
65
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.
|
|
65
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.44-beta.1 qingflow-skills install --agent codex --scope user --copy
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
`qingflow-skills install` 默认使用 symlink,且遇到同名 skill 不会覆盖;只有显式传入 `--force` 才会替换。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qingflow-tech/qingflow-cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.44-beta.1",
|
|
4
4
|
"description": "Human-friendly Qingflow command line interface for auth, record operations, import, tasks, and stable builder flows.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/pyproject.toml
CHANGED
|
@@ -246,6 +246,10 @@ current CLI payload.
|
|
|
246
246
|
| `q_linker` | **`config.qLinkerBinding`**:`inputs`、`request`、**`outputs[]` 每项须含 `targetField`**(能解析到本表单字段) | `request.url` 等须符合**租户/后端远程查询策略**;以字段类型 Schema 返回的配置为准 |
|
|
247
247
|
| `code_block` | **`config.codeBlockBinding`**:`inputs`、`code`、**`outputs[]` + `targetField`**;配置 outputs 时,代码必须显式写 **`qf_output = {...}`**,仅 `return {...}` 不会产生可回写结果,且每个 `outputs[].path` 必须从 `qf_output` 取值 | 以字段类型 Schema 返回的 camelCase 配置为准 |
|
|
248
248
|
|
|
249
|
+
部门字段的 `defaultDepartments` 也遵循同一约束:`custom` 模式必须包含至少一个部门。读取历史后端数据时,如果默认部门值存在但部门列表为空,AppForm 归一化会将其视为“未配置默认部门”并省略 `defaultDepartments`;不要在新声明中写 `departments: []`。
|
|
250
|
+
|
|
251
|
+
读取历史数据时,空的应用/段落/字段标题会按稳定 ID 生成兼容名称,空行和空筛选组会被忽略;选择项、子表列、序列号组件、Q-Linker 输出和代码源码等语义必需值无法安全推断时,读取会返回结构化 `UNSUPPORTED_FIELD_CONFIG`,不会伪造声明值。`validate/apply` 对新声明仍保持严格 Schema 约束。
|
|
252
|
+
|
|
249
253
|
别名:契约会把 `title`/`label` 映射到 **`name`** 等;字段类型自然别名见 §1.1。
|
|
250
254
|
|
|
251
255
|
### 2.1 `relation` 选择器形态
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from .errors import QingflowApiError
|
|
7
|
+
from .oauth_profile_store import OAuthProfileState, OAuthProfileStore
|
|
8
|
+
from .oauth_token_store import OAuthTokenBundle, OAuthTokenStore
|
|
9
|
+
from .session_store import BackendSession, SessionProfile, SessionStore
|
|
10
|
+
from .cli.oauth_client import CLI_OAUTH_CLIENT_ID, CLI_OAUTH_SOURCE_CODE, OAuthClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
OAUTH_REFRESH_SKEW = timedelta(seconds=90)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OAuthSessionProvider:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
profile_store: OAuthProfileStore,
|
|
20
|
+
token_store: OAuthTokenStore,
|
|
21
|
+
oauth_client: OAuthClient,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.profile_store = profile_store
|
|
24
|
+
self.token_store = token_store
|
|
25
|
+
self.oauth_client = oauth_client
|
|
26
|
+
|
|
27
|
+
def get_state(self, profile: str) -> OAuthProfileState | None:
|
|
28
|
+
return self.profile_store.get(profile)
|
|
29
|
+
|
|
30
|
+
def get_profile(self, profile: str, *, refresh: bool = True) -> SessionProfile:
|
|
31
|
+
state, bundle = self._resolve(profile, refresh=refresh)
|
|
32
|
+
return self._session_profile(state, token=bundle.access_token if refresh else None)
|
|
33
|
+
|
|
34
|
+
def get_backend_session(self, profile: str) -> BackendSession:
|
|
35
|
+
state, bundle = self._resolve(profile, refresh=True)
|
|
36
|
+
return BackendSession(
|
|
37
|
+
token=bundle.access_token,
|
|
38
|
+
login_token=None,
|
|
39
|
+
credential=None,
|
|
40
|
+
profile=profile,
|
|
41
|
+
base_url=state.base_url or "",
|
|
42
|
+
qf_version=state.qf_version,
|
|
43
|
+
qf_version_source=state.qf_version_source,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def activate(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
profile: str,
|
|
50
|
+
base_url: str,
|
|
51
|
+
qf_version: str | None,
|
|
52
|
+
qf_version_source: str | None,
|
|
53
|
+
uid: int,
|
|
54
|
+
email: str | None,
|
|
55
|
+
nick_name: str | None,
|
|
56
|
+
selected_ws_id: int | None,
|
|
57
|
+
selected_ws_name: str | None,
|
|
58
|
+
issuer: str,
|
|
59
|
+
resource: str,
|
|
60
|
+
bundle: OAuthTokenBundle,
|
|
61
|
+
) -> OAuthProfileState:
|
|
62
|
+
with self.profile_store.lock(profile):
|
|
63
|
+
previous_state = self.profile_store.get(profile)
|
|
64
|
+
previous_bundle = self.token_store.load(profile)
|
|
65
|
+
previous_storage = (
|
|
66
|
+
previous_state.secret_storage
|
|
67
|
+
if previous_state is not None and previous_state.active_auth_type == "oauth"
|
|
68
|
+
else None
|
|
69
|
+
)
|
|
70
|
+
storage = self.token_store.save(profile, bundle, storage=previous_storage, locked=True)
|
|
71
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
72
|
+
state = OAuthProfileState(
|
|
73
|
+
active_auth_type="oauth",
|
|
74
|
+
profile=profile,
|
|
75
|
+
base_url=base_url,
|
|
76
|
+
qf_version=qf_version,
|
|
77
|
+
qf_version_source=qf_version_source,
|
|
78
|
+
uid=uid,
|
|
79
|
+
email=email,
|
|
80
|
+
nick_name=nick_name,
|
|
81
|
+
selected_ws_id=selected_ws_id,
|
|
82
|
+
selected_ws_name=selected_ws_name,
|
|
83
|
+
persisted=True,
|
|
84
|
+
secret_storage=storage,
|
|
85
|
+
issuer=issuer,
|
|
86
|
+
client_id=CLI_OAUTH_CLIENT_ID,
|
|
87
|
+
resource=resource,
|
|
88
|
+
source_code=CLI_OAUTH_SOURCE_CODE,
|
|
89
|
+
access_expires_at=bundle.access_expires_at,
|
|
90
|
+
created_at=previous_state.created_at if previous_state is not None else now,
|
|
91
|
+
updated_at=now,
|
|
92
|
+
)
|
|
93
|
+
try:
|
|
94
|
+
self.profile_store.save(state, locked=True)
|
|
95
|
+
except Exception:
|
|
96
|
+
if previous_bundle is None:
|
|
97
|
+
self.token_store.delete(profile, locked=True)
|
|
98
|
+
else:
|
|
99
|
+
self.token_store.save(
|
|
100
|
+
profile,
|
|
101
|
+
previous_bundle,
|
|
102
|
+
storage=previous_storage,
|
|
103
|
+
locked=True,
|
|
104
|
+
)
|
|
105
|
+
if previous_state is None:
|
|
106
|
+
self.profile_store.delete(profile, locked=True)
|
|
107
|
+
else:
|
|
108
|
+
self.profile_store.save(previous_state, locked=True)
|
|
109
|
+
raise
|
|
110
|
+
return state
|
|
111
|
+
|
|
112
|
+
def refresh(self, profile: str, *, force: bool, observed_generation: int | None = None) -> bool:
|
|
113
|
+
with self.profile_store.lock(profile):
|
|
114
|
+
state = self._require_oauth_state(profile)
|
|
115
|
+
bundle = self._require_bundle(profile)
|
|
116
|
+
if observed_generation is not None and bundle.generation > observed_generation:
|
|
117
|
+
return True
|
|
118
|
+
if not force and not self._needs_refresh(bundle):
|
|
119
|
+
return True
|
|
120
|
+
metadata = self.oauth_client.discover(state.base_url or "")
|
|
121
|
+
try:
|
|
122
|
+
refreshed = self.oauth_client.refresh(
|
|
123
|
+
metadata,
|
|
124
|
+
refresh_token=bundle.refresh_token,
|
|
125
|
+
generation=bundle.generation,
|
|
126
|
+
)
|
|
127
|
+
except QingflowApiError as error:
|
|
128
|
+
oauth_error = (error.details or {}).get("oauth_error")
|
|
129
|
+
failure = "invalid_grant" if oauth_error == "invalid_grant" else "uncertain_result"
|
|
130
|
+
self._invalidate_refresh_bundle(profile)
|
|
131
|
+
details = {"reason": "oauth_relogin_required", "refresh_failure": failure}
|
|
132
|
+
if oauth_error:
|
|
133
|
+
details["oauth_error"] = oauth_error
|
|
134
|
+
raise QingflowApiError(
|
|
135
|
+
category="auth",
|
|
136
|
+
message=(
|
|
137
|
+
f"OAuth session for profile '{profile}' could not be safely refreshed. "
|
|
138
|
+
"Run auth login --method oauth again."
|
|
139
|
+
),
|
|
140
|
+
backend_code=error.backend_code,
|
|
141
|
+
request_id=error.request_id,
|
|
142
|
+
http_status=error.http_status,
|
|
143
|
+
details=details,
|
|
144
|
+
) from error
|
|
145
|
+
try:
|
|
146
|
+
storage = self.token_store.save(
|
|
147
|
+
profile,
|
|
148
|
+
refreshed,
|
|
149
|
+
storage=state.secret_storage,
|
|
150
|
+
locked=True,
|
|
151
|
+
)
|
|
152
|
+
state.secret_storage = storage
|
|
153
|
+
state.access_expires_at = refreshed.access_expires_at
|
|
154
|
+
self.profile_store.save(state, locked=True)
|
|
155
|
+
except Exception as error:
|
|
156
|
+
self._invalidate_refresh_bundle(profile)
|
|
157
|
+
raise QingflowApiError(
|
|
158
|
+
category="auth",
|
|
159
|
+
message=(
|
|
160
|
+
f"OAuth session for profile '{profile}' could not persist its refreshed token. "
|
|
161
|
+
"Run auth login --method oauth again."
|
|
162
|
+
),
|
|
163
|
+
details={
|
|
164
|
+
"reason": "oauth_relogin_required",
|
|
165
|
+
"refresh_failure": "local_persistence_failed",
|
|
166
|
+
},
|
|
167
|
+
) from error
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
def _invalidate_refresh_bundle(self, profile: str) -> None:
|
|
171
|
+
try:
|
|
172
|
+
self.token_store.delete(profile, locked=True)
|
|
173
|
+
except QingflowApiError:
|
|
174
|
+
pass
|
|
175
|
+
self.profile_store.set_none(profile, locked=True)
|
|
176
|
+
|
|
177
|
+
def update_workspace(self, profile: str, ws_id: int, ws_name: str | None) -> SessionProfile:
|
|
178
|
+
with self.profile_store.lock(profile):
|
|
179
|
+
state = self._require_oauth_state(profile)
|
|
180
|
+
state.selected_ws_id = ws_id
|
|
181
|
+
state.selected_ws_name = ws_name
|
|
182
|
+
self.profile_store.save(state, locked=True)
|
|
183
|
+
return self.get_profile(profile, refresh=False)
|
|
184
|
+
|
|
185
|
+
def update_route(
|
|
186
|
+
self,
|
|
187
|
+
profile: str,
|
|
188
|
+
*,
|
|
189
|
+
qf_version: str | None,
|
|
190
|
+
qf_version_source: str | None,
|
|
191
|
+
) -> SessionProfile:
|
|
192
|
+
with self.profile_store.lock(profile):
|
|
193
|
+
state = self._require_oauth_state(profile)
|
|
194
|
+
state.qf_version = _text(qf_version)
|
|
195
|
+
state.qf_version_source = _text(qf_version_source)
|
|
196
|
+
self.profile_store.save(state, locked=True)
|
|
197
|
+
return self.get_profile(profile, refresh=False)
|
|
198
|
+
|
|
199
|
+
def update_metadata(
|
|
200
|
+
self,
|
|
201
|
+
profile: str,
|
|
202
|
+
*,
|
|
203
|
+
uid: int | object,
|
|
204
|
+
email: str | None | object,
|
|
205
|
+
nick_name: str | None | object,
|
|
206
|
+
selected_ws_id: int | None | object,
|
|
207
|
+
selected_ws_name: str | None | object,
|
|
208
|
+
unset: object,
|
|
209
|
+
) -> SessionProfile:
|
|
210
|
+
with self.profile_store.lock(profile):
|
|
211
|
+
state = self._require_oauth_state(profile)
|
|
212
|
+
if uid is not unset:
|
|
213
|
+
state.uid = int(uid)
|
|
214
|
+
if email is not unset:
|
|
215
|
+
state.email = email if isinstance(email, str) or email is None else state.email
|
|
216
|
+
if nick_name is not unset:
|
|
217
|
+
state.nick_name = nick_name if isinstance(nick_name, str) or nick_name is None else state.nick_name
|
|
218
|
+
if selected_ws_id is not unset:
|
|
219
|
+
state.selected_ws_id = (
|
|
220
|
+
int(selected_ws_id)
|
|
221
|
+
if isinstance(selected_ws_id, int) and not isinstance(selected_ws_id, bool)
|
|
222
|
+
else None
|
|
223
|
+
)
|
|
224
|
+
if selected_ws_name is not unset:
|
|
225
|
+
state.selected_ws_name = (
|
|
226
|
+
selected_ws_name
|
|
227
|
+
if isinstance(selected_ws_name, str) or selected_ws_name is None
|
|
228
|
+
else state.selected_ws_name
|
|
229
|
+
)
|
|
230
|
+
self.profile_store.save(state, locked=True)
|
|
231
|
+
return self.get_profile(profile, refresh=False)
|
|
232
|
+
|
|
233
|
+
def logout(self, profile: str) -> list[dict[str, str]]:
|
|
234
|
+
with self.profile_store.lock(profile):
|
|
235
|
+
state = self._require_oauth_state(profile)
|
|
236
|
+
warnings: list[dict[str, str]] = []
|
|
237
|
+
bundle = self.token_store.load(profile)
|
|
238
|
+
if bundle is not None:
|
|
239
|
+
try:
|
|
240
|
+
self.oauth_client.revoke(state.base_url or "", bundle.refresh_token)
|
|
241
|
+
except QingflowApiError as error:
|
|
242
|
+
warnings.append(
|
|
243
|
+
{
|
|
244
|
+
"code": "OAUTH_REMOTE_REVOCATION_FAILED",
|
|
245
|
+
"message": error.message,
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
self.token_store.delete(profile, locked=True)
|
|
249
|
+
self.profile_store.set_none(profile, locked=True)
|
|
250
|
+
return warnings
|
|
251
|
+
|
|
252
|
+
def deactivate_for_legacy(self, profile: str) -> list[dict[str, str]]:
|
|
253
|
+
with self.profile_store.lock(profile):
|
|
254
|
+
state = self.get_state(profile)
|
|
255
|
+
if state is None:
|
|
256
|
+
return []
|
|
257
|
+
warnings: list[dict[str, str]] = []
|
|
258
|
+
if state.active_auth_type == "oauth":
|
|
259
|
+
bundle = self.token_store.load(profile)
|
|
260
|
+
if bundle is not None:
|
|
261
|
+
try:
|
|
262
|
+
self.oauth_client.revoke(state.base_url or "", bundle.refresh_token)
|
|
263
|
+
except QingflowApiError as error:
|
|
264
|
+
warnings.append(
|
|
265
|
+
{
|
|
266
|
+
"code": "OAUTH_REMOTE_REVOCATION_FAILED",
|
|
267
|
+
"message": error.message,
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
self.token_store.delete(profile, locked=True)
|
|
271
|
+
self.profile_store.delete(profile, locked=True)
|
|
272
|
+
return warnings
|
|
273
|
+
|
|
274
|
+
def _resolve(self, profile: str, *, refresh: bool) -> tuple[OAuthProfileState, OAuthTokenBundle]:
|
|
275
|
+
state = self._require_oauth_state(profile)
|
|
276
|
+
bundle = self._require_bundle(profile)
|
|
277
|
+
if refresh and self._needs_refresh(bundle):
|
|
278
|
+
try:
|
|
279
|
+
self.refresh(profile, force=False, observed_generation=bundle.generation)
|
|
280
|
+
state = self._require_oauth_state(profile)
|
|
281
|
+
bundle = self._require_bundle(profile)
|
|
282
|
+
except QingflowApiError as error:
|
|
283
|
+
if error.category != "network" or self._is_expired(bundle):
|
|
284
|
+
raise
|
|
285
|
+
return state, bundle
|
|
286
|
+
|
|
287
|
+
def _require_oauth_state(self, profile: str) -> OAuthProfileState:
|
|
288
|
+
state = self.get_state(profile)
|
|
289
|
+
if state is None or state.active_auth_type != "oauth":
|
|
290
|
+
raise QingflowApiError.auth_required(profile)
|
|
291
|
+
return state
|
|
292
|
+
|
|
293
|
+
def _require_bundle(self, profile: str) -> OAuthTokenBundle:
|
|
294
|
+
bundle = self.token_store.load(profile)
|
|
295
|
+
if bundle is None:
|
|
296
|
+
raise QingflowApiError(
|
|
297
|
+
category="auth",
|
|
298
|
+
message=(
|
|
299
|
+
f"OAuth session for profile '{profile}' is missing its token bundle. "
|
|
300
|
+
"Run auth login --method oauth again."
|
|
301
|
+
),
|
|
302
|
+
details={"reason": "oauth_relogin_required"},
|
|
303
|
+
)
|
|
304
|
+
return bundle
|
|
305
|
+
|
|
306
|
+
def _session_profile(self, state: OAuthProfileState, *, token: str | None) -> SessionProfile:
|
|
307
|
+
return SessionProfile(
|
|
308
|
+
profile=state.profile,
|
|
309
|
+
base_url=state.base_url or "",
|
|
310
|
+
qf_version=state.qf_version,
|
|
311
|
+
qf_version_source=state.qf_version_source,
|
|
312
|
+
token=token,
|
|
313
|
+
login_token=None,
|
|
314
|
+
credential=None,
|
|
315
|
+
uid=int(state.uid or 0),
|
|
316
|
+
email=state.email,
|
|
317
|
+
nick_name=state.nick_name,
|
|
318
|
+
selected_ws_id=state.selected_ws_id,
|
|
319
|
+
selected_ws_name=state.selected_ws_name,
|
|
320
|
+
persisted=True,
|
|
321
|
+
created_at=state.created_at,
|
|
322
|
+
updated_at=state.updated_at,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def _needs_refresh(self, bundle: OAuthTokenBundle) -> bool:
|
|
326
|
+
return self._expires_at(bundle) <= datetime.now(timezone.utc) + OAUTH_REFRESH_SKEW
|
|
327
|
+
|
|
328
|
+
def _is_expired(self, bundle: OAuthTokenBundle) -> bool:
|
|
329
|
+
return self._expires_at(bundle) <= datetime.now(timezone.utc)
|
|
330
|
+
|
|
331
|
+
def _expires_at(self, bundle: OAuthTokenBundle) -> datetime:
|
|
332
|
+
try:
|
|
333
|
+
value = datetime.fromisoformat(bundle.access_expires_at)
|
|
334
|
+
except ValueError as exc:
|
|
335
|
+
raise QingflowApiError.config_error("OAuth access_expires_at is invalid.") from exc
|
|
336
|
+
if value.tzinfo is None:
|
|
337
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
338
|
+
return value.astimezone(timezone.utc)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class CliSessionFacade:
|
|
342
|
+
def __init__(self, legacy: SessionStore, oauth: OAuthSessionProvider) -> None:
|
|
343
|
+
self.legacy = legacy
|
|
344
|
+
self.oauth = oauth
|
|
345
|
+
self._pending_legacy_profiles: set[str] = set()
|
|
346
|
+
|
|
347
|
+
def set_profile_refresher(self, refresher: Callable[[str, SessionProfile | None], bool] | None) -> None:
|
|
348
|
+
self.legacy.set_profile_refresher(refresher)
|
|
349
|
+
|
|
350
|
+
def oauth_state(self, profile: str) -> str | None:
|
|
351
|
+
state = self.oauth.get_state(profile)
|
|
352
|
+
return state.active_auth_type if state is not None else None
|
|
353
|
+
|
|
354
|
+
def uses_oauth(self, profile: str) -> bool:
|
|
355
|
+
return profile not in self._pending_legacy_profiles and self.oauth_state(profile) == "oauth"
|
|
356
|
+
|
|
357
|
+
def blocks_legacy(self, profile: str) -> bool:
|
|
358
|
+
return profile not in self._pending_legacy_profiles and self.oauth_state(profile) in {"oauth", "none"}
|
|
359
|
+
|
|
360
|
+
def save_session(self, **kwargs) -> SessionProfile: # type: ignore[no-untyped-def]
|
|
361
|
+
profile = str(kwargs.get("profile") or "")
|
|
362
|
+
oauth_state = self.oauth_state(profile)
|
|
363
|
+
saved = self.legacy.save_session(**kwargs)
|
|
364
|
+
if oauth_state in {"oauth", "none"}:
|
|
365
|
+
self._pending_legacy_profiles.add(profile)
|
|
366
|
+
return saved
|
|
367
|
+
|
|
368
|
+
def get_profile(self, profile: str) -> SessionProfile | None:
|
|
369
|
+
if profile in self._pending_legacy_profiles:
|
|
370
|
+
return self.legacy.get_profile(profile)
|
|
371
|
+
state = self.oauth_state(profile)
|
|
372
|
+
if state == "oauth":
|
|
373
|
+
return self.oauth.get_profile(profile)
|
|
374
|
+
if state == "none":
|
|
375
|
+
return None
|
|
376
|
+
return self.legacy.get_profile(profile)
|
|
377
|
+
|
|
378
|
+
def peek_profile(self, profile: str) -> SessionProfile | None:
|
|
379
|
+
if profile in self._pending_legacy_profiles:
|
|
380
|
+
return self.legacy.peek_profile(profile)
|
|
381
|
+
state = self.oauth_state(profile)
|
|
382
|
+
if state == "oauth":
|
|
383
|
+
return self.oauth.get_profile(profile, refresh=False)
|
|
384
|
+
if state == "none":
|
|
385
|
+
return None
|
|
386
|
+
return self.legacy.peek_profile(profile)
|
|
387
|
+
|
|
388
|
+
def get_backend_session(self, profile: str) -> BackendSession | None:
|
|
389
|
+
if profile in self._pending_legacy_profiles:
|
|
390
|
+
return self.legacy.get_backend_session(profile)
|
|
391
|
+
state = self.oauth_state(profile)
|
|
392
|
+
if state == "oauth":
|
|
393
|
+
return self.oauth.get_backend_session(profile)
|
|
394
|
+
if state == "none":
|
|
395
|
+
return None
|
|
396
|
+
return self.legacy.get_backend_session(profile)
|
|
397
|
+
|
|
398
|
+
def select_workspace(self, profile: str, ws_id: int, ws_name: str | None) -> SessionProfile:
|
|
399
|
+
if self.uses_oauth(profile):
|
|
400
|
+
return self.oauth.update_workspace(profile, ws_id, ws_name)
|
|
401
|
+
if self.blocks_legacy(profile):
|
|
402
|
+
raise KeyError(profile)
|
|
403
|
+
return self.legacy.select_workspace(profile, ws_id, ws_name)
|
|
404
|
+
|
|
405
|
+
def update_route(
|
|
406
|
+
self,
|
|
407
|
+
profile: str,
|
|
408
|
+
*,
|
|
409
|
+
qf_version: str | None,
|
|
410
|
+
qf_version_source: str | None,
|
|
411
|
+
) -> SessionProfile:
|
|
412
|
+
if self.uses_oauth(profile):
|
|
413
|
+
return self.oauth.update_route(
|
|
414
|
+
profile,
|
|
415
|
+
qf_version=qf_version,
|
|
416
|
+
qf_version_source=qf_version_source,
|
|
417
|
+
)
|
|
418
|
+
if self.blocks_legacy(profile):
|
|
419
|
+
raise KeyError(profile)
|
|
420
|
+
return self.legacy.update_route(
|
|
421
|
+
profile,
|
|
422
|
+
qf_version=qf_version,
|
|
423
|
+
qf_version_source=qf_version_source,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
def update_profile_metadata(self, profile: str, **kwargs) -> SessionProfile: # type: ignore[no-untyped-def]
|
|
427
|
+
if self.uses_oauth(profile):
|
|
428
|
+
unset = object()
|
|
429
|
+
return self.oauth.update_metadata(
|
|
430
|
+
profile,
|
|
431
|
+
uid=kwargs.get("uid", unset),
|
|
432
|
+
email=kwargs.get("email", unset),
|
|
433
|
+
nick_name=kwargs.get("nick_name", unset),
|
|
434
|
+
selected_ws_id=kwargs.get("selected_ws_id", unset),
|
|
435
|
+
selected_ws_name=kwargs.get("selected_ws_name", unset),
|
|
436
|
+
unset=unset,
|
|
437
|
+
)
|
|
438
|
+
if self.blocks_legacy(profile):
|
|
439
|
+
raise KeyError(profile)
|
|
440
|
+
return self.legacy.update_profile_metadata(profile, **kwargs)
|
|
441
|
+
|
|
442
|
+
def logout(self, profile: str, forget_persisted: bool = False) -> None:
|
|
443
|
+
if self.uses_oauth(profile):
|
|
444
|
+
self.oauth.logout(profile)
|
|
445
|
+
return
|
|
446
|
+
if self.oauth_state(profile) == "none":
|
|
447
|
+
return
|
|
448
|
+
self.legacy.logout(profile, forget_persisted=forget_persisted)
|
|
449
|
+
|
|
450
|
+
def invalidate(self, profile: str) -> None:
|
|
451
|
+
if self.uses_oauth(profile):
|
|
452
|
+
self.oauth.token_store.delete(profile)
|
|
453
|
+
return
|
|
454
|
+
if self.oauth_state(profile) == "none":
|
|
455
|
+
return
|
|
456
|
+
self.legacy.invalidate(profile)
|
|
457
|
+
|
|
458
|
+
def has_profile(self, profile: str) -> bool:
|
|
459
|
+
if self.uses_oauth(profile):
|
|
460
|
+
return True
|
|
461
|
+
if self.oauth_state(profile) == "none":
|
|
462
|
+
return False
|
|
463
|
+
return self.legacy.has_profile(profile)
|
|
464
|
+
|
|
465
|
+
def activate_oauth(self, **kwargs) -> OAuthProfileState: # type: ignore[no-untyped-def]
|
|
466
|
+
profile = str(kwargs.get("profile") or "")
|
|
467
|
+
state = self.oauth.activate(**kwargs)
|
|
468
|
+
self._pending_legacy_profiles.discard(profile)
|
|
469
|
+
return state
|
|
470
|
+
|
|
471
|
+
def complete_legacy_login(self, profile: str) -> list[dict[str, str]]:
|
|
472
|
+
warnings = self.oauth.deactivate_for_legacy(profile)
|
|
473
|
+
self._pending_legacy_profiles.discard(profile)
|
|
474
|
+
return warnings
|
|
475
|
+
|
|
476
|
+
def begin_legacy_login(self, profile: str) -> None:
|
|
477
|
+
self._pending_legacy_profiles.add(profile)
|
|
478
|
+
|
|
479
|
+
def cancel_legacy_login(self, profile: str) -> None:
|
|
480
|
+
self._pending_legacy_profiles.discard(profile)
|
|
481
|
+
|
|
482
|
+
def oauth_logout(self, profile: str) -> list[dict[str, str]]:
|
|
483
|
+
self._pending_legacy_profiles.discard(profile)
|
|
484
|
+
return self.oauth.logout(profile)
|
|
485
|
+
|
|
486
|
+
def refresh_oauth_session(self, profile: str, *, force: bool = True) -> bool:
|
|
487
|
+
if not self.uses_oauth(profile):
|
|
488
|
+
return False
|
|
489
|
+
backend = self.oauth.token_store.load(profile)
|
|
490
|
+
generation = backend.generation if backend is not None else None
|
|
491
|
+
return self.oauth.refresh(profile, force=force, observed_generation=generation)
|
|
492
|
+
|
|
493
|
+
def active_auth_type(self, profile: str) -> str:
|
|
494
|
+
if self.uses_oauth(profile):
|
|
495
|
+
return "oauth"
|
|
496
|
+
if self.oauth_state(profile) == "none":
|
|
497
|
+
return "none"
|
|
498
|
+
session_profile = self.legacy.peek_profile(profile)
|
|
499
|
+
if session_profile is None:
|
|
500
|
+
return "none"
|
|
501
|
+
return "credential" if _text(session_profile.credential) else "legacy_token"
|
|
502
|
+
|
|
503
|
+
def oauth_details(self, profile: str) -> dict[str, object]:
|
|
504
|
+
state = self.oauth.get_state(profile)
|
|
505
|
+
if state is None or state.active_auth_type != "oauth":
|
|
506
|
+
return {}
|
|
507
|
+
return {
|
|
508
|
+
"active_auth_type": "oauth",
|
|
509
|
+
"auth_source": "oauth_pkce",
|
|
510
|
+
"source_code": state.source_code,
|
|
511
|
+
"access_expires_at": state.access_expires_at,
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
def close(self) -> None:
|
|
515
|
+
self.oauth.oauth_client.close()
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _text(value: object) -> str | None:
|
|
519
|
+
if value is None:
|
|
520
|
+
return None
|
|
521
|
+
normalized = str(value).strip()
|
|
522
|
+
return normalized or None
|