@josephyan/qingflow-cli 0.2.0-beta.1000
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 +31 -0
- package/docs/local-agent-install.md +309 -0
- package/entry_point.py +13 -0
- package/npm/bin/qingflow.mjs +5 -0
- package/npm/lib/runtime.mjs +346 -0
- package/npm/scripts/postinstall.mjs +16 -0
- package/package.json +34 -0
- package/pyproject.toml +67 -0
- package/qingflow +15 -0
- package/src/qingflow_mcp/__init__.py +37 -0
- package/src/qingflow_mcp/__main__.py +5 -0
- package/src/qingflow_mcp/backend_client.py +649 -0
- package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
- package/src/qingflow_mcp/builder_facade/models.py +1846 -0
- package/src/qingflow_mcp/builder_facade/service.py +16502 -0
- package/src/qingflow_mcp/cli/__init__.py +1 -0
- package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
- package/src/qingflow_mcp/cli/commands/app.py +40 -0
- package/src/qingflow_mcp/cli/commands/auth.py +112 -0
- package/src/qingflow_mcp/cli/commands/builder.py +539 -0
- package/src/qingflow_mcp/cli/commands/chart.py +18 -0
- package/src/qingflow_mcp/cli/commands/common.py +62 -0
- package/src/qingflow_mcp/cli/commands/imports.py +96 -0
- package/src/qingflow_mcp/cli/commands/portal.py +25 -0
- package/src/qingflow_mcp/cli/commands/record.py +331 -0
- package/src/qingflow_mcp/cli/commands/repo.py +80 -0
- package/src/qingflow_mcp/cli/commands/task.py +141 -0
- package/src/qingflow_mcp/cli/commands/view.py +18 -0
- package/src/qingflow_mcp/cli/commands/workspace.py +110 -0
- package/src/qingflow_mcp/cli/context.py +60 -0
- package/src/qingflow_mcp/cli/formatters.py +573 -0
- package/src/qingflow_mcp/cli/json_io.py +50 -0
- package/src/qingflow_mcp/cli/main.py +186 -0
- package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
- package/src/qingflow_mcp/cli/terminal_ui.py +173 -0
- package/src/qingflow_mcp/config.py +407 -0
- package/src/qingflow_mcp/errors.py +66 -0
- package/src/qingflow_mcp/id_utils.py +49 -0
- package/src/qingflow_mcp/import_store.py +121 -0
- package/src/qingflow_mcp/json_types.py +18 -0
- package/src/qingflow_mcp/list_type_labels.py +76 -0
- package/src/qingflow_mcp/public_surface.py +243 -0
- package/src/qingflow_mcp/repository_store.py +71 -0
- package/src/qingflow_mcp/response_trim.py +841 -0
- package/src/qingflow_mcp/server.py +216 -0
- package/src/qingflow_mcp/server_app_builder.py +543 -0
- package/src/qingflow_mcp/server_app_user.py +386 -0
- package/src/qingflow_mcp/session_store.py +369 -0
- package/src/qingflow_mcp/solution/__init__.py +6 -0
- package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
- package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
- package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
- package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
- package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
- package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
- package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
- package/src/qingflow_mcp/solution/design_session.py +222 -0
- package/src/qingflow_mcp/solution/design_store.py +100 -0
- package/src/qingflow_mcp/solution/executor.py +2398 -0
- package/src/qingflow_mcp/solution/normalizer.py +23 -0
- package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
- package/src/qingflow_mcp/solution/run_store.py +244 -0
- package/src/qingflow_mcp/solution/spec_models.py +855 -0
- package/src/qingflow_mcp/tools/__init__.py +1 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +3449 -0
- package/src/qingflow_mcp/tools/app_tools.py +926 -0
- package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
- package/src/qingflow_mcp/tools/auth_tools.py +1133 -0
- package/src/qingflow_mcp/tools/base.py +281 -0
- package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
- package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
- package/src/qingflow_mcp/tools/directory_tools.py +675 -0
- package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
- package/src/qingflow_mcp/tools/file_tools.py +409 -0
- package/src/qingflow_mcp/tools/import_tools.py +2223 -0
- package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
- package/src/qingflow_mcp/tools/package_tools.py +326 -0
- package/src/qingflow_mcp/tools/portal_tools.py +158 -0
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
- package/src/qingflow_mcp/tools/record_tools.py +14291 -0
- package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
- package/src/qingflow_mcp/tools/resource_read_tools.py +503 -0
- package/src/qingflow_mcp/tools/role_tools.py +112 -0
- package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
- package/src/qingflow_mcp/tools/task_context_tools.py +2986 -0
- package/src/qingflow_mcp/tools/task_tools.py +889 -0
- package/src/qingflow_mcp/tools/view_tools.py +335 -0
- package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
- package/src/qingflow_mcp/tools/workspace_tools.py +266 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import keyring
|
|
11
|
+
except ImportError:
|
|
12
|
+
keyring = None
|
|
13
|
+
|
|
14
|
+
from .config import get_profiles_path, normalize_base_url
|
|
15
|
+
from .json_types import JSONObject, KeyringBackend
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
KEYRING_SERVICE_NAME = "qingflow-mcp"
|
|
19
|
+
_UNSET = object()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _utcnow() -> str:
|
|
23
|
+
return datetime.now(timezone.utc).isoformat()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class SessionProfile:
|
|
28
|
+
profile: str
|
|
29
|
+
base_url: str
|
|
30
|
+
qf_version: str | None
|
|
31
|
+
qf_version_source: str | None
|
|
32
|
+
token: str | None
|
|
33
|
+
login_token: str | None
|
|
34
|
+
credential: str | None
|
|
35
|
+
uid: int
|
|
36
|
+
email: str | None
|
|
37
|
+
nick_name: str | None
|
|
38
|
+
selected_ws_id: int | None
|
|
39
|
+
selected_ws_name: str | None
|
|
40
|
+
persisted: bool
|
|
41
|
+
created_at: str
|
|
42
|
+
updated_at: str
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_dict(cls, value: JSONObject) -> "SessionProfile":
|
|
46
|
+
return cls(
|
|
47
|
+
profile=value["profile"],
|
|
48
|
+
base_url=value["base_url"],
|
|
49
|
+
qf_version=value.get("qf_version"),
|
|
50
|
+
qf_version_source=value.get("qf_version_source"),
|
|
51
|
+
token=value.get("token"),
|
|
52
|
+
login_token=value.get("login_token"),
|
|
53
|
+
credential=value.get("credential"),
|
|
54
|
+
uid=value["uid"],
|
|
55
|
+
email=value.get("email"),
|
|
56
|
+
nick_name=value.get("nick_name"),
|
|
57
|
+
selected_ws_id=value.get("selected_ws_id"),
|
|
58
|
+
selected_ws_name=value.get("selected_ws_name"),
|
|
59
|
+
persisted=bool(value.get("persisted", False)),
|
|
60
|
+
created_at=value.get("created_at", _utcnow()),
|
|
61
|
+
updated_at=value.get("updated_at", _utcnow()),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(slots=True)
|
|
66
|
+
class BackendSession:
|
|
67
|
+
token: str
|
|
68
|
+
login_token: str | None
|
|
69
|
+
credential: str | None
|
|
70
|
+
profile: str
|
|
71
|
+
base_url: str
|
|
72
|
+
qf_version: str | None
|
|
73
|
+
qf_version_source: str | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SessionStore:
|
|
77
|
+
def __init__(self, base_dir: Path | None = None, keyring_backend: KeyringBackend | None = None) -> None:
|
|
78
|
+
profiles_path = get_profiles_path() if base_dir is None else Path(base_dir) / "profiles.json"
|
|
79
|
+
self._profiles_path = profiles_path
|
|
80
|
+
self._profiles_path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
self._secrets_path = self._profiles_path.parent / "secrets.json"
|
|
82
|
+
self._keyring = keyring_backend if keyring_backend is not None else keyring
|
|
83
|
+
self._memory_sessions: dict[str, BackendSession] = {}
|
|
84
|
+
self._logged_out_profiles: set[str] = set()
|
|
85
|
+
|
|
86
|
+
def save_session(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
profile: str,
|
|
90
|
+
base_url: str,
|
|
91
|
+
qf_version: str | None,
|
|
92
|
+
qf_version_source: str | None = None,
|
|
93
|
+
token: str,
|
|
94
|
+
login_token: str | None,
|
|
95
|
+
credential: str | None = None,
|
|
96
|
+
uid: int,
|
|
97
|
+
email: str | None,
|
|
98
|
+
nick_name: str | None,
|
|
99
|
+
persist: bool,
|
|
100
|
+
) -> SessionProfile:
|
|
101
|
+
now = _utcnow()
|
|
102
|
+
previous = self.get_profile(profile)
|
|
103
|
+
persisted = False
|
|
104
|
+
if persist:
|
|
105
|
+
persisted = self._set_secret(self._token_key(profile), token)
|
|
106
|
+
if login_token:
|
|
107
|
+
self._set_secret(self._login_token_key(profile), login_token)
|
|
108
|
+
else:
|
|
109
|
+
self._delete_secret(self._login_token_key(profile))
|
|
110
|
+
if credential:
|
|
111
|
+
self._set_secret(self._credential_key(profile), credential)
|
|
112
|
+
else:
|
|
113
|
+
self._delete_secret(self._credential_key(profile))
|
|
114
|
+
else:
|
|
115
|
+
self._delete_secret(self._token_key(profile))
|
|
116
|
+
self._delete_secret(self._login_token_key(profile))
|
|
117
|
+
self._delete_secret(self._credential_key(profile))
|
|
118
|
+
session_profile = SessionProfile(
|
|
119
|
+
profile=profile,
|
|
120
|
+
base_url=normalize_base_url(base_url) or base_url,
|
|
121
|
+
qf_version=(str(qf_version).strip() or None) if qf_version is not None else None,
|
|
122
|
+
qf_version_source=(str(qf_version_source).strip() or None) if qf_version_source is not None else None,
|
|
123
|
+
token=str(token).strip() or None,
|
|
124
|
+
login_token=(str(login_token).strip() or None) if login_token is not None else None,
|
|
125
|
+
credential=(str(credential).strip() or None) if credential is not None else None,
|
|
126
|
+
uid=uid,
|
|
127
|
+
email=email,
|
|
128
|
+
nick_name=nick_name,
|
|
129
|
+
selected_ws_id=None,
|
|
130
|
+
selected_ws_name=None,
|
|
131
|
+
persisted=persisted,
|
|
132
|
+
created_at=previous.created_at if previous else now,
|
|
133
|
+
updated_at=now,
|
|
134
|
+
)
|
|
135
|
+
self._memory_sessions[profile] = BackendSession(
|
|
136
|
+
token=session_profile.token or token,
|
|
137
|
+
login_token=session_profile.login_token,
|
|
138
|
+
credential=session_profile.credential,
|
|
139
|
+
profile=profile,
|
|
140
|
+
base_url=session_profile.base_url,
|
|
141
|
+
qf_version=session_profile.qf_version,
|
|
142
|
+
qf_version_source=session_profile.qf_version_source,
|
|
143
|
+
)
|
|
144
|
+
self._logged_out_profiles.discard(profile)
|
|
145
|
+
self._upsert_profile(session_profile)
|
|
146
|
+
return session_profile
|
|
147
|
+
|
|
148
|
+
def get_profile(self, profile: str) -> SessionProfile | None:
|
|
149
|
+
payload = self._load_profiles()
|
|
150
|
+
raw_profile = payload.get("profiles", {}).get(profile)
|
|
151
|
+
if not raw_profile:
|
|
152
|
+
return None
|
|
153
|
+
return SessionProfile.from_dict(raw_profile)
|
|
154
|
+
|
|
155
|
+
def get_backend_session(self, profile: str) -> BackendSession | None:
|
|
156
|
+
if profile in self._logged_out_profiles:
|
|
157
|
+
return None
|
|
158
|
+
memory_session = self._memory_sessions.get(profile)
|
|
159
|
+
session_profile = self.get_profile(profile)
|
|
160
|
+
if memory_session:
|
|
161
|
+
if session_profile is not None:
|
|
162
|
+
memory_session.base_url = session_profile.base_url
|
|
163
|
+
memory_session.qf_version = session_profile.qf_version
|
|
164
|
+
memory_session.qf_version_source = session_profile.qf_version_source
|
|
165
|
+
return memory_session
|
|
166
|
+
if not session_profile:
|
|
167
|
+
return None
|
|
168
|
+
token = self._get_secret(self._token_key(profile)) if session_profile.persisted else None
|
|
169
|
+
if not token:
|
|
170
|
+
token = session_profile.token
|
|
171
|
+
if not token:
|
|
172
|
+
return None
|
|
173
|
+
login_token = self._get_secret(self._login_token_key(profile)) if session_profile.persisted else None
|
|
174
|
+
credential = self._get_secret(self._credential_key(profile)) if session_profile.persisted else None
|
|
175
|
+
backend_session = BackendSession(
|
|
176
|
+
token=token,
|
|
177
|
+
login_token=login_token or session_profile.login_token,
|
|
178
|
+
credential=credential or session_profile.credential,
|
|
179
|
+
profile=profile,
|
|
180
|
+
base_url=session_profile.base_url,
|
|
181
|
+
qf_version=session_profile.qf_version,
|
|
182
|
+
qf_version_source=session_profile.qf_version_source,
|
|
183
|
+
)
|
|
184
|
+
self._memory_sessions[profile] = backend_session
|
|
185
|
+
return backend_session
|
|
186
|
+
|
|
187
|
+
def select_workspace(self, profile: str, ws_id: int, ws_name: str | None) -> SessionProfile:
|
|
188
|
+
session_profile = self.get_profile(profile)
|
|
189
|
+
if session_profile is None:
|
|
190
|
+
raise KeyError(profile)
|
|
191
|
+
session_profile.selected_ws_id = ws_id
|
|
192
|
+
session_profile.selected_ws_name = ws_name
|
|
193
|
+
session_profile.updated_at = _utcnow()
|
|
194
|
+
self._upsert_profile(session_profile)
|
|
195
|
+
return session_profile
|
|
196
|
+
|
|
197
|
+
def update_route(self, profile: str, *, qf_version: str | None, qf_version_source: str | None) -> SessionProfile:
|
|
198
|
+
session_profile = self.get_profile(profile)
|
|
199
|
+
if session_profile is None:
|
|
200
|
+
raise KeyError(profile)
|
|
201
|
+
session_profile.qf_version = (str(qf_version).strip() or None) if qf_version is not None else None
|
|
202
|
+
session_profile.qf_version_source = (str(qf_version_source).strip() or None) if qf_version_source is not None else None
|
|
203
|
+
session_profile.updated_at = _utcnow()
|
|
204
|
+
self._upsert_profile(session_profile)
|
|
205
|
+
backend_session = self._memory_sessions.get(profile)
|
|
206
|
+
if backend_session is not None:
|
|
207
|
+
backend_session.qf_version = session_profile.qf_version
|
|
208
|
+
backend_session.qf_version_source = session_profile.qf_version_source
|
|
209
|
+
return session_profile
|
|
210
|
+
|
|
211
|
+
def update_profile_metadata(
|
|
212
|
+
self,
|
|
213
|
+
profile: str,
|
|
214
|
+
*,
|
|
215
|
+
uid: int | object = _UNSET,
|
|
216
|
+
email: str | None | object = _UNSET,
|
|
217
|
+
nick_name: str | None | object = _UNSET,
|
|
218
|
+
selected_ws_id: int | None | object = _UNSET,
|
|
219
|
+
selected_ws_name: str | None | object = _UNSET,
|
|
220
|
+
) -> SessionProfile:
|
|
221
|
+
session_profile = self.get_profile(profile)
|
|
222
|
+
if session_profile is None:
|
|
223
|
+
raise KeyError(profile)
|
|
224
|
+
if uid is not _UNSET:
|
|
225
|
+
session_profile.uid = int(uid)
|
|
226
|
+
if email is not _UNSET:
|
|
227
|
+
session_profile.email = email if isinstance(email, str) or email is None else session_profile.email
|
|
228
|
+
if nick_name is not _UNSET:
|
|
229
|
+
session_profile.nick_name = nick_name if isinstance(nick_name, str) or nick_name is None else session_profile.nick_name
|
|
230
|
+
if selected_ws_id is not _UNSET:
|
|
231
|
+
session_profile.selected_ws_id = (
|
|
232
|
+
int(selected_ws_id)
|
|
233
|
+
if isinstance(selected_ws_id, int) and not isinstance(selected_ws_id, bool)
|
|
234
|
+
else None
|
|
235
|
+
)
|
|
236
|
+
if selected_ws_name is not _UNSET:
|
|
237
|
+
session_profile.selected_ws_name = (
|
|
238
|
+
selected_ws_name
|
|
239
|
+
if isinstance(selected_ws_name, str) or selected_ws_name is None
|
|
240
|
+
else session_profile.selected_ws_name
|
|
241
|
+
)
|
|
242
|
+
session_profile.updated_at = _utcnow()
|
|
243
|
+
self._upsert_profile(session_profile)
|
|
244
|
+
return session_profile
|
|
245
|
+
|
|
246
|
+
def logout(self, profile: str, forget_persisted: bool = False) -> None:
|
|
247
|
+
self._memory_sessions.pop(profile, None)
|
|
248
|
+
if forget_persisted:
|
|
249
|
+
self.invalidate(profile)
|
|
250
|
+
return
|
|
251
|
+
if self.get_profile(profile):
|
|
252
|
+
self._logged_out_profiles.add(profile)
|
|
253
|
+
|
|
254
|
+
def invalidate(self, profile: str) -> None:
|
|
255
|
+
self._memory_sessions.pop(profile, None)
|
|
256
|
+
self._logged_out_profiles.discard(profile)
|
|
257
|
+
self._delete_secret(self._token_key(profile))
|
|
258
|
+
self._delete_secret(self._login_token_key(profile))
|
|
259
|
+
self._delete_secret(self._credential_key(profile))
|
|
260
|
+
payload = self._load_profiles()
|
|
261
|
+
profiles = payload.get("profiles", {})
|
|
262
|
+
if profile in profiles:
|
|
263
|
+
profiles.pop(profile)
|
|
264
|
+
self._save_profiles(payload)
|
|
265
|
+
|
|
266
|
+
def has_profile(self, profile: str) -> bool:
|
|
267
|
+
return self.get_profile(profile) is not None
|
|
268
|
+
|
|
269
|
+
def _token_key(self, profile: str) -> str:
|
|
270
|
+
return f"{profile}:token"
|
|
271
|
+
|
|
272
|
+
def _login_token_key(self, profile: str) -> str:
|
|
273
|
+
return f"{profile}:login-token"
|
|
274
|
+
|
|
275
|
+
def _credential_key(self, profile: str) -> str:
|
|
276
|
+
return f"{profile}:credential"
|
|
277
|
+
|
|
278
|
+
def _upsert_profile(self, profile: SessionProfile) -> None:
|
|
279
|
+
payload = self._load_profiles()
|
|
280
|
+
payload.setdefault("profiles", {})[profile.profile] = asdict(profile)
|
|
281
|
+
self._save_profiles(payload)
|
|
282
|
+
|
|
283
|
+
def _load_profiles(self) -> JSONObject:
|
|
284
|
+
if not self._profiles_path.exists():
|
|
285
|
+
return {"profiles": {}}
|
|
286
|
+
with self._profiles_path.open("r", encoding="utf-8") as handle:
|
|
287
|
+
return json.load(handle)
|
|
288
|
+
|
|
289
|
+
def _save_profiles(self, payload: JSONObject) -> None:
|
|
290
|
+
self._profiles_path.parent.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
with self._profiles_path.open("w", encoding="utf-8") as handle:
|
|
292
|
+
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
|
293
|
+
|
|
294
|
+
def _set_secret(self, key: str, value: str) -> bool:
|
|
295
|
+
if self._keyring is not None:
|
|
296
|
+
try:
|
|
297
|
+
self._keyring.set_password(KEYRING_SERVICE_NAME, key, value)
|
|
298
|
+
self._delete_file_secret(key)
|
|
299
|
+
return True
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
return self._set_file_secret(key, value)
|
|
303
|
+
|
|
304
|
+
def _get_secret(self, key: str) -> str | None:
|
|
305
|
+
if self._keyring is not None:
|
|
306
|
+
try:
|
|
307
|
+
value = self._keyring.get_password(KEYRING_SERVICE_NAME, key)
|
|
308
|
+
except Exception:
|
|
309
|
+
value = None
|
|
310
|
+
if value:
|
|
311
|
+
return value
|
|
312
|
+
return self._get_file_secret(key)
|
|
313
|
+
|
|
314
|
+
def _delete_secret(self, key: str) -> None:
|
|
315
|
+
if self._keyring is not None:
|
|
316
|
+
try:
|
|
317
|
+
self._keyring.delete_password(KEYRING_SERVICE_NAME, key)
|
|
318
|
+
except Exception:
|
|
319
|
+
pass
|
|
320
|
+
self._delete_file_secret(key)
|
|
321
|
+
|
|
322
|
+
def _load_file_secrets(self) -> dict[str, str]:
|
|
323
|
+
if not self._secrets_path.exists():
|
|
324
|
+
return {}
|
|
325
|
+
try:
|
|
326
|
+
with self._secrets_path.open("r", encoding="utf-8") as handle:
|
|
327
|
+
payload = json.load(handle)
|
|
328
|
+
except (OSError, json.JSONDecodeError):
|
|
329
|
+
return {}
|
|
330
|
+
if not isinstance(payload, dict):
|
|
331
|
+
return {}
|
|
332
|
+
return {str(key): str(value) for key, value in payload.items() if isinstance(value, str)}
|
|
333
|
+
|
|
334
|
+
def _save_file_secrets(self, payload: dict[str, str]) -> bool:
|
|
335
|
+
self._secrets_path.parent.mkdir(parents=True, exist_ok=True)
|
|
336
|
+
try:
|
|
337
|
+
fd = os.open(self._secrets_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
338
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
339
|
+
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
|
340
|
+
try:
|
|
341
|
+
os.chmod(self._secrets_path, 0o600)
|
|
342
|
+
except OSError:
|
|
343
|
+
pass
|
|
344
|
+
return True
|
|
345
|
+
except OSError:
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
def _set_file_secret(self, key: str, value: str) -> bool:
|
|
349
|
+
payload = self._load_file_secrets()
|
|
350
|
+
payload[key] = value
|
|
351
|
+
return self._save_file_secrets(payload)
|
|
352
|
+
|
|
353
|
+
def _get_file_secret(self, key: str) -> str | None:
|
|
354
|
+
return self._load_file_secrets().get(key)
|
|
355
|
+
|
|
356
|
+
def _delete_file_secret(self, key: str) -> None:
|
|
357
|
+
payload = self._load_file_secrets()
|
|
358
|
+
if key not in payload:
|
|
359
|
+
return
|
|
360
|
+
payload.pop(key, None)
|
|
361
|
+
if payload:
|
|
362
|
+
self._save_file_secrets(payload)
|
|
363
|
+
return
|
|
364
|
+
try:
|
|
365
|
+
self._secrets_path.unlink()
|
|
366
|
+
except FileNotFoundError:
|
|
367
|
+
return
|
|
368
|
+
except OSError:
|
|
369
|
+
return
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import tempfile
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..config import get_mcp_home
|
|
12
|
+
from .run_store import resolve_storage_path, utc_now
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_build_assemblies_path() -> Path:
|
|
16
|
+
custom_home = os.getenv("QINGFLOW_MCP_BUILD_HOME")
|
|
17
|
+
if custom_home:
|
|
18
|
+
return Path(custom_home).expanduser()
|
|
19
|
+
return get_mcp_home() / "build-assemblies"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def default_manifest() -> dict[str, Any]:
|
|
23
|
+
return {
|
|
24
|
+
"solution_name": "",
|
|
25
|
+
"summary": None,
|
|
26
|
+
"business_context": {},
|
|
27
|
+
"package": {"enabled": True, "ordinal": 1},
|
|
28
|
+
"entities": [],
|
|
29
|
+
"roles": [],
|
|
30
|
+
"requirements": [],
|
|
31
|
+
"success_metrics": [],
|
|
32
|
+
"portal": {"enabled": False, "sections": []},
|
|
33
|
+
"navigation": {"enabled": False, "items": []},
|
|
34
|
+
"publish_policy": {"apps": True, "portal": True, "navigation": True},
|
|
35
|
+
"preferences": {
|
|
36
|
+
"multi_app": None,
|
|
37
|
+
"create_package": True,
|
|
38
|
+
"create_portal": False,
|
|
39
|
+
"create_navigation": False,
|
|
40
|
+
"naming_style": "title",
|
|
41
|
+
},
|
|
42
|
+
"assumptions": [],
|
|
43
|
+
"constraints": [],
|
|
44
|
+
"metadata": {},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def default_artifacts() -> dict[str, Any]:
|
|
49
|
+
return {
|
|
50
|
+
"package": {},
|
|
51
|
+
"roles": {},
|
|
52
|
+
"apps": {},
|
|
53
|
+
"views": {},
|
|
54
|
+
"charts": {},
|
|
55
|
+
"records": {},
|
|
56
|
+
"portal": {},
|
|
57
|
+
"navigation": {},
|
|
58
|
+
"field_maps": {},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def default_builder_state() -> dict[str, Any]:
|
|
63
|
+
return {
|
|
64
|
+
"generated_specs": {},
|
|
65
|
+
"failure_signatures": {},
|
|
66
|
+
"last_failure_signature": None,
|
|
67
|
+
"last_failure_stage": None,
|
|
68
|
+
"last_failure_count": 0,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(slots=True)
|
|
73
|
+
class BuildAssemblyStore:
|
|
74
|
+
path: Path
|
|
75
|
+
data: dict[str, Any]
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def open(cls, *, build_id: str, create: bool = True) -> "BuildAssemblyStore":
|
|
79
|
+
base_dir = get_build_assemblies_path()
|
|
80
|
+
try:
|
|
81
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
except PermissionError:
|
|
83
|
+
base_dir = Path(tempfile.gettempdir()) / "qingflow-mcp-build-assemblies"
|
|
84
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
path = resolve_storage_path(base_dir, key=build_id, id_field="build_id")
|
|
86
|
+
if path.exists():
|
|
87
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
88
|
+
stored_build_id = data.get("build_id")
|
|
89
|
+
if stored_build_id != build_id:
|
|
90
|
+
raise ValueError(f"existing build assembly at '{path}' belongs to '{stored_build_id}', not '{build_id}'")
|
|
91
|
+
elif not create:
|
|
92
|
+
raise FileNotFoundError(build_id)
|
|
93
|
+
else:
|
|
94
|
+
data = {
|
|
95
|
+
"build_id": build_id,
|
|
96
|
+
"status": "draft",
|
|
97
|
+
"manifest": default_manifest(),
|
|
98
|
+
"stage_specs": {
|
|
99
|
+
"app_flow": {},
|
|
100
|
+
"views": {},
|
|
101
|
+
"analytics_portal": {},
|
|
102
|
+
"navigation": {},
|
|
103
|
+
},
|
|
104
|
+
"artifacts": default_artifacts(),
|
|
105
|
+
"builder_state": default_builder_state(),
|
|
106
|
+
"stage_history": [],
|
|
107
|
+
"created_at": utc_now(),
|
|
108
|
+
"updated_at": utc_now(),
|
|
109
|
+
}
|
|
110
|
+
try:
|
|
111
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
112
|
+
except PermissionError:
|
|
113
|
+
base_dir = Path(tempfile.gettempdir()) / "qingflow-mcp-build-assemblies"
|
|
114
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
path = resolve_storage_path(base_dir, key=build_id, id_field="build_id")
|
|
116
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
117
|
+
return cls(path=path, data=data)
|
|
118
|
+
|
|
119
|
+
def get_manifest(self) -> dict[str, Any]:
|
|
120
|
+
return deepcopy(self.data.get("manifest", default_manifest()))
|
|
121
|
+
|
|
122
|
+
def set_manifest(self, manifest: dict[str, Any]) -> None:
|
|
123
|
+
self.data["manifest"] = deepcopy(manifest)
|
|
124
|
+
self._flush()
|
|
125
|
+
|
|
126
|
+
def get_artifacts(self) -> dict[str, Any]:
|
|
127
|
+
return deepcopy(self.data.get("artifacts", default_artifacts()))
|
|
128
|
+
|
|
129
|
+
def set_artifacts(self, artifacts: dict[str, Any]) -> None:
|
|
130
|
+
self.data["artifacts"] = deepcopy(artifacts)
|
|
131
|
+
self._flush()
|
|
132
|
+
|
|
133
|
+
def set_stage_spec(self, stage_name: str, spec: dict[str, Any]) -> None:
|
|
134
|
+
self.data.setdefault("stage_specs", {})
|
|
135
|
+
self.data["stage_specs"][stage_name] = deepcopy(spec)
|
|
136
|
+
self._flush()
|
|
137
|
+
|
|
138
|
+
def add_stage_history(self, entry: dict[str, Any]) -> None:
|
|
139
|
+
self.data.setdefault("stage_history", []).append(deepcopy(entry))
|
|
140
|
+
self._flush()
|
|
141
|
+
|
|
142
|
+
def mark_status(self, status: str) -> None:
|
|
143
|
+
self.data["status"] = status
|
|
144
|
+
self._flush()
|
|
145
|
+
|
|
146
|
+
def get_builder_state(self) -> dict[str, Any]:
|
|
147
|
+
return deepcopy(self.data.get("builder_state", default_builder_state()))
|
|
148
|
+
|
|
149
|
+
def set_builder_state(self, builder_state: dict[str, Any]) -> None:
|
|
150
|
+
self.data["builder_state"] = deepcopy(builder_state)
|
|
151
|
+
self._flush()
|
|
152
|
+
|
|
153
|
+
def get_builder_value(self, key: str, default: Any = None) -> Any:
|
|
154
|
+
return deepcopy(self.data.get("builder_state", {}).get(key, default))
|
|
155
|
+
|
|
156
|
+
def set_builder_value(self, key: str, value: Any) -> None:
|
|
157
|
+
self.data.setdefault("builder_state", default_builder_state())
|
|
158
|
+
self.data["builder_state"][key] = deepcopy(value)
|
|
159
|
+
self._flush()
|
|
160
|
+
|
|
161
|
+
def summary(self) -> dict[str, Any]:
|
|
162
|
+
return {
|
|
163
|
+
"build_id": self.data["build_id"],
|
|
164
|
+
"status": self.data["status"],
|
|
165
|
+
"manifest": deepcopy(self.data.get("manifest", {})),
|
|
166
|
+
"artifacts": deepcopy(self.data.get("artifacts", {})),
|
|
167
|
+
"builder_state": deepcopy(self.data.get("builder_state", {})),
|
|
168
|
+
"stage_specs": deepcopy(self.data.get("stage_specs", {})),
|
|
169
|
+
"stage_history": deepcopy(self.data.get("stage_history", [])),
|
|
170
|
+
"build_path": str(self.path),
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
def _flush(self) -> None:
|
|
174
|
+
self.data["updated_at"] = utc_now()
|
|
175
|
+
try:
|
|
176
|
+
self.path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
177
|
+
except PermissionError:
|
|
178
|
+
fallback_dir = Path(tempfile.gettempdir()) / "qingflow-mcp-build-assemblies"
|
|
179
|
+
fallback_dir.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
self.path = resolve_storage_path(fallback_dir, key=str(self.data.get("build_id") or "build"), id_field="build_id")
|
|
181
|
+
self.path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2), encoding="utf-8")
|