buildify-cli 1.0.0__py3-none-any.whl
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.
- buildify_cli/__init__.py +1 -0
- buildify_cli/__main__.py +4 -0
- buildify_cli/cli.py +1206 -0
- buildify_cli/client.py +288 -0
- buildify_cli/config.py +593 -0
- buildify_cli/credential_form.py +156 -0
- buildify_cli/exit_codes.py +8 -0
- buildify_cli/output.py +46 -0
- buildify_cli/publish_view.py +122 -0
- buildify_cli/usage.md +245 -0
- buildify_cli-1.0.0.dist-info/METADATA +95 -0
- buildify_cli-1.0.0.dist-info/RECORD +15 -0
- buildify_cli-1.0.0.dist-info/WHEEL +5 -0
- buildify_cli-1.0.0.dist-info/entry_points.txt +2 -0
- buildify_cli-1.0.0.dist-info/top_level.txt +1 -0
buildify_cli/config.py
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import stat
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import tomllib # py311+
|
|
12
|
+
except ModuleNotFoundError:
|
|
13
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
14
|
+
|
|
15
|
+
import tomli_w
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "https://openapi.buildify.cn"
|
|
18
|
+
ENV_BASE_URL = "BUILDIFY_OPENAPI_BASE_URL"
|
|
19
|
+
ENV_API_KEY = "BUILDIFY_OPENAPI_API_KEY"
|
|
20
|
+
ENV_PROFILE = "BUILDIFY_OPENAPI_PROFILE"
|
|
21
|
+
ENV_JSON = "BUILDIFY_OPENAPI_JSON"
|
|
22
|
+
# Set to 1/true/yes to never prompt for a missing key (CI / non-interactive).
|
|
23
|
+
ENV_NO_PROMPT = "BUILDIFY_OPENAPI_NO_PROMPT"
|
|
24
|
+
|
|
25
|
+
# ~/.buildifyrc 与 buildify-publish 共用;开放 API 的配置放在独立 section 下,
|
|
26
|
+
# 避免两套密钥(发布 / 编排)互相覆盖。
|
|
27
|
+
RC_SECTION = "openapi"
|
|
28
|
+
|
|
29
|
+
RC_KEYS = frozenset({"base_url", "api_key"})
|
|
30
|
+
|
|
31
|
+
RC_FILE_MODE = stat.S_IRUSR | stat.S_IWUSR # 0600 — owner read/write only
|
|
32
|
+
|
|
33
|
+
# 旧的单密钥写在 [openapi].api_key,列出时当作这个名字。与 profiles.default 冲突时以具名档为准。
|
|
34
|
+
LEGACY_PROFILE_NAME = "default"
|
|
35
|
+
WORKSPACE_FILENAME = ".buildify-workspace"
|
|
36
|
+
PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
37
|
+
_PROFILE_META_KEYS = ("tenant_id", "key_name", "key_prefix")
|
|
38
|
+
|
|
39
|
+
MISSING_API_KEY_REASON = "missing_api_key"
|
|
40
|
+
PROFILE_REQUIRED_REASON = "profile_required"
|
|
41
|
+
PROFILE_NOT_FOUND_REASON = "profile_not_found"
|
|
42
|
+
WORKSPACE_KEY_MISSING_REASON = "workspace_key_missing"
|
|
43
|
+
|
|
44
|
+
API_KEY_SETUP_GUIDE = """如何获取:
|
|
45
|
+
1. 打开 Buildify 控制台,进入当前工作空间
|
|
46
|
+
2. 空间设置 → 开放 API 密钥 → 创建
|
|
47
|
+
3. 复制完整密钥(格式 keyId.secret)。明文只显示一次,请立刻保存;之后只能看到前缀
|
|
48
|
+
|
|
49
|
+
如何保存到本机(任选其一,<名字> 自定,不会改默认空间):
|
|
50
|
+
- 交互输入(推荐,输入时隐藏):buildify config add-profile <名字>
|
|
51
|
+
- 管道写入,避免出现在 argv / shell 历史:
|
|
52
|
+
printf '%s' '你的密钥' | buildify config add-profile <名字>
|
|
53
|
+
- 只有一把密钥时也可以:buildify config set-key api_key
|
|
54
|
+
|
|
55
|
+
密钥写入 ~/.buildifyrc 的 [openapi] 段,文件权限 600(仅当前用户可读写)。
|
|
56
|
+
|
|
57
|
+
安全注意:
|
|
58
|
+
- 不要使用 --api-key 或把密钥写在命令行参数里(会出现在进程列表和 shell 历史)
|
|
59
|
+
- 不要把密钥写进项目仓库、会提交的 .env、issue 或聊天记录
|
|
60
|
+
- 不要截图完整密钥;配置文件不要提交到 git
|
|
61
|
+
- Agent 保存后只应展示掩码(config show),不要回显明文"""
|
|
62
|
+
|
|
63
|
+
MISSING_API_KEY_USER_MESSAGE = (
|
|
64
|
+
"未配置开放 API 密钥,无法调用编排接口。\n\n" + API_KEY_SETUP_GUIDE
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
MISSING_API_KEY_AGENT_INSTRUCTIONS = (
|
|
68
|
+
"Stop authoring. Show the user how to create an Open API key in the console "
|
|
69
|
+
"(workspace settings → Open API keys → Create) and ask them to provide the "
|
|
70
|
+
"full keyId.secret once. Save with stdin: printf '%s' '<key>' | buildify "
|
|
71
|
+
"config add-profile <name>. Never echo the secret, never pass --api-key, never "
|
|
72
|
+
"write it into the repo, never run `config use` unless they asked to change "
|
|
73
|
+
"the default workspace. Then run: buildify --json key test."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
PROFILE_REQUIRED_USER_MESSAGE = (
|
|
77
|
+
"本机保存了多个工作空间的密钥,这次没有选定用哪一个。\n"
|
|
78
|
+
"请选择配置档名称,之后的命令加上 --profile <name>。\n"
|
|
79
|
+
"不要执行 config use,除非明确要求更换默认工作空间。"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
PROFILE_REQUIRED_AGENT_INSTRUCTIONS = (
|
|
83
|
+
"Stop. Show the user the profiles list (name, tenantId, keyName) and ask which "
|
|
84
|
+
"workspace to use once. Then pass --profile <name> on every subsequent command. "
|
|
85
|
+
"Do not run `buildify config use` unless the user explicitly asked to change the "
|
|
86
|
+
"default workspace. Never print api_key or the secret."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
WORKSPACE_KEY_MISSING_USER_MESSAGE = (
|
|
90
|
+
"当前项目钉住了一个工作空间,但本机没有对上的开放 API 密钥。\n"
|
|
91
|
+
"请在该工作空间的控制台创建密钥,再用 config add-profile 保存。"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
WORKSPACE_KEY_MISSING_AGENT_INSTRUCTIONS = (
|
|
95
|
+
"Stop. This repo is pinned to a workspace id and no local profile matches it. "
|
|
96
|
+
"Ask the user to create an Open API key in that workspace and save it with "
|
|
97
|
+
"stdin: printf '%s' '<key>' | buildify config add-profile <name>. "
|
|
98
|
+
"Do not author against a different workspace. Never echo the secret, never "
|
|
99
|
+
"pass --api-key, never run `config use` unless they asked to change the default."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def config_file_path() -> Path:
|
|
104
|
+
"""~/.buildifyrc — TOML section [openapi], keys: base_url, api_key."""
|
|
105
|
+
return Path.home() / ".buildifyrc"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class StoredProfile:
|
|
110
|
+
"""One local key. ``legacy`` means it is stored as [openapi].api_key, not under profiles."""
|
|
111
|
+
|
|
112
|
+
name: str
|
|
113
|
+
api_key: str
|
|
114
|
+
tenant_id: Optional[str] = None
|
|
115
|
+
key_name: Optional[str] = None
|
|
116
|
+
key_prefix: Optional[str] = None
|
|
117
|
+
legacy: bool = False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class ResolvedConfig:
|
|
122
|
+
base_url: str
|
|
123
|
+
api_key: Optional[str]
|
|
124
|
+
json_output: bool
|
|
125
|
+
profile: Optional[str] = None
|
|
126
|
+
tenant_id: Optional[str] = None
|
|
127
|
+
# Set when a key cannot be chosen (multiple profiles, unknown name, or workspace pin miss).
|
|
128
|
+
auth_error: Optional[dict[str, Any]] = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_toml_file(path: Path) -> dict[str, Any]:
|
|
132
|
+
if not path.is_file():
|
|
133
|
+
return {}
|
|
134
|
+
return tomllib.loads(path.read_bytes().decode("utf-8"))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def load_raw_config() -> dict[str, Any]:
|
|
138
|
+
return _read_toml_file(config_file_path())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_file_config() -> dict[str, Any]:
|
|
142
|
+
section = load_raw_config().get(RC_SECTION)
|
|
143
|
+
return section if isinstance(section, dict) else {}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def normalize_base_url(url: str) -> str:
|
|
147
|
+
return url.strip().rstrip("/")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _opt_str(value: Any) -> Optional[str]:
|
|
151
|
+
if value is None:
|
|
152
|
+
return None
|
|
153
|
+
text = str(value).strip()
|
|
154
|
+
return text or None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def validate_profile_name(name: str) -> str:
|
|
158
|
+
n = name.strip()
|
|
159
|
+
if not PROFILE_NAME_RE.match(n):
|
|
160
|
+
raise ValueError(
|
|
161
|
+
"profile name must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}"
|
|
162
|
+
)
|
|
163
|
+
return n
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def list_stored_profiles(section: Optional[dict[str, Any]] = None) -> list[StoredProfile]:
|
|
167
|
+
"""Named profiles plus the legacy [openapi].api_key as ``default`` when that name is free."""
|
|
168
|
+
if section is None:
|
|
169
|
+
section = load_file_config()
|
|
170
|
+
found: list[StoredProfile] = []
|
|
171
|
+
names: set[str] = set()
|
|
172
|
+
table = section.get("profiles")
|
|
173
|
+
if isinstance(table, dict):
|
|
174
|
+
for raw_name, body in table.items():
|
|
175
|
+
if not isinstance(body, dict):
|
|
176
|
+
continue
|
|
177
|
+
key = _opt_str(body.get("api_key"))
|
|
178
|
+
if not key:
|
|
179
|
+
continue
|
|
180
|
+
name = str(raw_name)
|
|
181
|
+
names.add(name)
|
|
182
|
+
found.append(StoredProfile(
|
|
183
|
+
name=name,
|
|
184
|
+
api_key=key,
|
|
185
|
+
tenant_id=_opt_str(body.get("tenant_id")),
|
|
186
|
+
key_name=_opt_str(body.get("key_name")),
|
|
187
|
+
key_prefix=_opt_str(body.get("key_prefix")),
|
|
188
|
+
))
|
|
189
|
+
legacy = _opt_str(section.get("api_key"))
|
|
190
|
+
if legacy and LEGACY_PROFILE_NAME not in names:
|
|
191
|
+
found.append(StoredProfile(
|
|
192
|
+
name=LEGACY_PROFILE_NAME,
|
|
193
|
+
api_key=legacy,
|
|
194
|
+
tenant_id=_opt_str(section.get("tenant_id")),
|
|
195
|
+
key_name=_opt_str(section.get("key_name")),
|
|
196
|
+
key_prefix=_opt_str(section.get("key_prefix")),
|
|
197
|
+
legacy=True,
|
|
198
|
+
))
|
|
199
|
+
return found
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def profile_public(profile: StoredProfile, current: Optional[str]) -> dict[str, Any]:
|
|
203
|
+
"""Fields safe to print. Never includes api_key."""
|
|
204
|
+
return {
|
|
205
|
+
"name": profile.name,
|
|
206
|
+
"tenantId": profile.tenant_id,
|
|
207
|
+
"keyName": profile.key_name,
|
|
208
|
+
"keyPrefix": profile.key_prefix,
|
|
209
|
+
"current": profile.name == current,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _public_profiles(profiles: list[StoredProfile], current: Optional[str]) -> list[dict[str, Any]]:
|
|
214
|
+
return [profile_public(p, current) for p in profiles]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def find_workspace_id(start: Optional[Path] = None) -> Optional[str]:
|
|
218
|
+
"""Walk from ``start`` (default cwd) toward the filesystem root for ``.buildify-workspace``."""
|
|
219
|
+
cur = (start or Path.cwd()).resolve()
|
|
220
|
+
if cur.is_file():
|
|
221
|
+
cur = cur.parent
|
|
222
|
+
for directory in (cur, *cur.parents):
|
|
223
|
+
candidate = directory / WORKSPACE_FILENAME
|
|
224
|
+
if not candidate.is_file():
|
|
225
|
+
continue
|
|
226
|
+
workspace_id = _read_workspace_id(candidate)
|
|
227
|
+
if workspace_id:
|
|
228
|
+
return workspace_id
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _read_workspace_id(path: Path) -> Optional[str]:
|
|
233
|
+
try:
|
|
234
|
+
text = path.read_text(encoding="utf-8")
|
|
235
|
+
except OSError:
|
|
236
|
+
return None
|
|
237
|
+
for line in text.splitlines():
|
|
238
|
+
stripped = line.strip()
|
|
239
|
+
if stripped and not stripped.startswith("#"):
|
|
240
|
+
return stripped
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _selected(base: str, json_output: bool, profile: StoredProfile) -> ResolvedConfig:
|
|
245
|
+
return ResolvedConfig(
|
|
246
|
+
base_url=base,
|
|
247
|
+
api_key=profile.api_key,
|
|
248
|
+
json_output=json_output,
|
|
249
|
+
profile=profile.name,
|
|
250
|
+
tenant_id=profile.tenant_id,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def resolve_config(
|
|
255
|
+
*,
|
|
256
|
+
cli_base_url: Optional[str],
|
|
257
|
+
cli_api_key: Optional[str],
|
|
258
|
+
cli_json: bool = False,
|
|
259
|
+
cli_profile: Optional[str] = None,
|
|
260
|
+
cwd: Optional[Path] = None,
|
|
261
|
+
) -> ResolvedConfig:
|
|
262
|
+
file_cfg = load_file_config()
|
|
263
|
+
base = cli_base_url or os.environ.get(ENV_BASE_URL) or file_cfg.get("base_url")
|
|
264
|
+
if not base or not str(base).strip():
|
|
265
|
+
base = DEFAULT_BASE_URL
|
|
266
|
+
base = normalize_base_url(str(base))
|
|
267
|
+
|
|
268
|
+
json_env = os.environ.get(ENV_JSON, "").strip().lower() in ("1", "true", "yes")
|
|
269
|
+
json_output = bool(cli_json) or json_env
|
|
270
|
+
profiles = list_stored_profiles(file_cfg)
|
|
271
|
+
by_name = {p.name: p for p in profiles}
|
|
272
|
+
current = _opt_str(file_cfg.get("current"))
|
|
273
|
+
|
|
274
|
+
explicit = _opt_str(cli_api_key) or _opt_str(os.environ.get(ENV_API_KEY))
|
|
275
|
+
if explicit:
|
|
276
|
+
matched = next((p for p in profiles if p.api_key == explicit), None)
|
|
277
|
+
return ResolvedConfig(
|
|
278
|
+
base_url=base,
|
|
279
|
+
api_key=explicit,
|
|
280
|
+
json_output=json_output,
|
|
281
|
+
profile=matched.name if matched else None,
|
|
282
|
+
tenant_id=matched.tenant_id if matched else None,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
requested = _opt_str(cli_profile) or _opt_str(os.environ.get(ENV_PROFILE))
|
|
286
|
+
if requested:
|
|
287
|
+
found = by_name.get(requested)
|
|
288
|
+
if found is None:
|
|
289
|
+
return ResolvedConfig(
|
|
290
|
+
base_url=base,
|
|
291
|
+
api_key=None,
|
|
292
|
+
json_output=json_output,
|
|
293
|
+
auth_error=profile_not_found_payload(requested, profiles, current),
|
|
294
|
+
)
|
|
295
|
+
return _selected(base, json_output, found)
|
|
296
|
+
|
|
297
|
+
workspace_id = find_workspace_id(cwd)
|
|
298
|
+
if workspace_id:
|
|
299
|
+
matches = [p for p in profiles if p.tenant_id == workspace_id]
|
|
300
|
+
if len(matches) == 1:
|
|
301
|
+
return _selected(base, json_output, matches[0])
|
|
302
|
+
if len(matches) > 1:
|
|
303
|
+
current_match = next((p for p in matches if p.name == current), None)
|
|
304
|
+
if current_match is not None:
|
|
305
|
+
return _selected(base, json_output, current_match)
|
|
306
|
+
return ResolvedConfig(
|
|
307
|
+
base_url=base,
|
|
308
|
+
api_key=None,
|
|
309
|
+
json_output=json_output,
|
|
310
|
+
auth_error=profile_required_payload(matches, current, workspace_id),
|
|
311
|
+
)
|
|
312
|
+
return ResolvedConfig(
|
|
313
|
+
base_url=base,
|
|
314
|
+
api_key=None,
|
|
315
|
+
json_output=json_output,
|
|
316
|
+
auth_error=workspace_key_missing_payload(workspace_id, profiles, current),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if current and current in by_name:
|
|
320
|
+
return _selected(base, json_output, by_name[current])
|
|
321
|
+
|
|
322
|
+
if len(profiles) == 1:
|
|
323
|
+
return _selected(base, json_output, profiles[0])
|
|
324
|
+
|
|
325
|
+
if not profiles:
|
|
326
|
+
return ResolvedConfig(base_url=base, api_key=None, json_output=json_output)
|
|
327
|
+
|
|
328
|
+
return ResolvedConfig(
|
|
329
|
+
base_url=base,
|
|
330
|
+
api_key=None,
|
|
331
|
+
json_output=json_output,
|
|
332
|
+
auth_error=profile_required_payload(profiles, current),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def env_flag(name: str) -> bool:
|
|
337
|
+
return os.environ.get(name, "").strip().lower() in ("1", "true", "yes")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def normalize_api_key(value: str) -> str:
|
|
341
|
+
"""Strip and reject empty / whitespace / obviously malformed keys."""
|
|
342
|
+
v = value.strip()
|
|
343
|
+
if not v:
|
|
344
|
+
raise ValueError("api_key cannot be empty")
|
|
345
|
+
if any(c.isspace() for c in v):
|
|
346
|
+
raise ValueError("api_key must not contain whitespace")
|
|
347
|
+
if "." not in v:
|
|
348
|
+
raise ValueError("api_key must be keyId.secret")
|
|
349
|
+
return v
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def missing_api_key_payload() -> dict[str, Any]:
|
|
353
|
+
"""Machine-readable hint for agents when no key is configured."""
|
|
354
|
+
return {
|
|
355
|
+
"reason": MISSING_API_KEY_REASON,
|
|
356
|
+
"configPath": str(config_file_path()),
|
|
357
|
+
"section": RC_SECTION,
|
|
358
|
+
"howToGet": [
|
|
359
|
+
"Open the Buildify console and enter the current workspace",
|
|
360
|
+
"Workspace settings → Open API keys → Create",
|
|
361
|
+
"Copy the full key (keyId.secret). The secret is shown only once",
|
|
362
|
+
],
|
|
363
|
+
"howToSave": "printf '%s' '<key>' | buildify config add-profile <name>",
|
|
364
|
+
"userMessage": MISSING_API_KEY_USER_MESSAGE.strip(),
|
|
365
|
+
"agentInstructions": MISSING_API_KEY_AGENT_INSTRUCTIONS,
|
|
366
|
+
"security": [
|
|
367
|
+
"Do not pass --api-key (visible in process list and shell history)",
|
|
368
|
+
"Do not write the key into the project tree or commit it",
|
|
369
|
+
f"{config_file_path()} is created with mode 600",
|
|
370
|
+
"After saving, only show the masked value from `config show`",
|
|
371
|
+
],
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def profile_required_payload(
|
|
376
|
+
profiles: list[StoredProfile],
|
|
377
|
+
current: Optional[str],
|
|
378
|
+
workspace_id: Optional[str] = None,
|
|
379
|
+
) -> dict[str, Any]:
|
|
380
|
+
data: dict[str, Any] = {
|
|
381
|
+
"reason": PROFILE_REQUIRED_REASON,
|
|
382
|
+
"profiles": _public_profiles(profiles, current),
|
|
383
|
+
"userMessage": PROFILE_REQUIRED_USER_MESSAGE.strip(),
|
|
384
|
+
"agentInstructions": PROFILE_REQUIRED_AGENT_INSTRUCTIONS,
|
|
385
|
+
"howToSelect": "buildify --profile <name> --json key test",
|
|
386
|
+
}
|
|
387
|
+
if workspace_id:
|
|
388
|
+
data["workspaceId"] = workspace_id
|
|
389
|
+
return data
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def profile_not_found_payload(
|
|
393
|
+
name: str,
|
|
394
|
+
profiles: list[StoredProfile],
|
|
395
|
+
current: Optional[str],
|
|
396
|
+
) -> dict[str, Any]:
|
|
397
|
+
return {
|
|
398
|
+
"reason": PROFILE_NOT_FOUND_REASON,
|
|
399
|
+
"profile": name,
|
|
400
|
+
"profiles": _public_profiles(profiles, current),
|
|
401
|
+
"userMessage": f"没有名为 {name} 的配置档。请从已保存的配置档里选一个。",
|
|
402
|
+
"agentInstructions": PROFILE_REQUIRED_AGENT_INSTRUCTIONS,
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def workspace_key_missing_payload(
|
|
407
|
+
workspace_id: str,
|
|
408
|
+
profiles: list[StoredProfile],
|
|
409
|
+
current: Optional[str],
|
|
410
|
+
) -> dict[str, Any]:
|
|
411
|
+
return {
|
|
412
|
+
"reason": WORKSPACE_KEY_MISSING_REASON,
|
|
413
|
+
"workspaceId": workspace_id,
|
|
414
|
+
"profiles": _public_profiles(profiles, current),
|
|
415
|
+
"userMessage": WORKSPACE_KEY_MISSING_USER_MESSAGE.strip(),
|
|
416
|
+
"agentInstructions": WORKSPACE_KEY_MISSING_AGENT_INSTRUCTIONS,
|
|
417
|
+
"howToSave": "printf '%s' '<key>' | buildify config add-profile <name>",
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _ordered(value: Any) -> Any:
|
|
422
|
+
"""tomli_w rejects a table key that appears before a scalar at the same level."""
|
|
423
|
+
if not isinstance(value, dict):
|
|
424
|
+
return value
|
|
425
|
+
scalars = {k: v for k, v in value.items() if not isinstance(v, dict)}
|
|
426
|
+
tables = {k: _ordered(v) for k, v in value.items() if isinstance(v, dict)}
|
|
427
|
+
return {**scalars, **tables}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _save_raw(raw: dict[str, Any]) -> Path:
|
|
431
|
+
path = config_file_path()
|
|
432
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
433
|
+
path.write_text(tomli_w.dumps(_ordered(raw)), encoding="utf-8")
|
|
434
|
+
try:
|
|
435
|
+
path.chmod(RC_FILE_MODE)
|
|
436
|
+
except OSError:
|
|
437
|
+
pass
|
|
438
|
+
return path
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _section_for_update() -> tuple[dict[str, Any], dict[str, Any]]:
|
|
442
|
+
raw = load_raw_config()
|
|
443
|
+
section = raw.get(RC_SECTION)
|
|
444
|
+
if not isinstance(section, dict):
|
|
445
|
+
section = {}
|
|
446
|
+
raw[RC_SECTION] = section
|
|
447
|
+
return raw, section
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _write_section(updates: dict[str, Any]) -> Path:
|
|
451
|
+
raw, section = _section_for_update()
|
|
452
|
+
section.update(updates)
|
|
453
|
+
raw[RC_SECTION] = section
|
|
454
|
+
return _save_raw(raw)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _clear_meta(body: dict[str, Any]) -> None:
|
|
458
|
+
for key in _PROFILE_META_KEYS:
|
|
459
|
+
body.pop(key, None)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def save_profile(name: str, api_key: str) -> Path:
|
|
463
|
+
"""Store a key under profiles.<name>. Does not change ``current``."""
|
|
464
|
+
n = validate_profile_name(name)
|
|
465
|
+
key = normalize_api_key(api_key)
|
|
466
|
+
raw, section = _section_for_update()
|
|
467
|
+
if n == LEGACY_PROFILE_NAME and not isinstance(section.get("profiles"), dict):
|
|
468
|
+
section["api_key"] = key
|
|
469
|
+
_clear_meta(section)
|
|
470
|
+
elif n == LEGACY_PROFILE_NAME and LEGACY_PROFILE_NAME not in (section.get("profiles") or {}):
|
|
471
|
+
section["api_key"] = key
|
|
472
|
+
_clear_meta(section)
|
|
473
|
+
else:
|
|
474
|
+
table = section.get("profiles")
|
|
475
|
+
if not isinstance(table, dict):
|
|
476
|
+
table = {}
|
|
477
|
+
section["profiles"] = table
|
|
478
|
+
body = table.get(n)
|
|
479
|
+
if not isinstance(body, dict):
|
|
480
|
+
body = {}
|
|
481
|
+
table[n] = body
|
|
482
|
+
body["api_key"] = key
|
|
483
|
+
_clear_meta(body)
|
|
484
|
+
raw[RC_SECTION] = section
|
|
485
|
+
return _save_raw(raw)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def set_current_profile(name: str) -> Path:
|
|
489
|
+
n = validate_profile_name(name)
|
|
490
|
+
raw, section = _section_for_update()
|
|
491
|
+
known = {p.name for p in list_stored_profiles(section)}
|
|
492
|
+
if n not in known:
|
|
493
|
+
raise ValueError(f"profile {n!r} does not exist")
|
|
494
|
+
section["current"] = n
|
|
495
|
+
raw[RC_SECTION] = section
|
|
496
|
+
return _save_raw(raw)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def remove_profile(name: str) -> Path:
|
|
500
|
+
n = validate_profile_name(name)
|
|
501
|
+
raw, section = _section_for_update()
|
|
502
|
+
table = section.get("profiles")
|
|
503
|
+
removed = False
|
|
504
|
+
if isinstance(table, dict) and n in table:
|
|
505
|
+
del table[n]
|
|
506
|
+
if not table:
|
|
507
|
+
section.pop("profiles", None)
|
|
508
|
+
removed = True
|
|
509
|
+
if n == LEGACY_PROFILE_NAME and _opt_str(section.get("api_key")):
|
|
510
|
+
section.pop("api_key", None)
|
|
511
|
+
_clear_meta(section)
|
|
512
|
+
removed = True
|
|
513
|
+
if not removed:
|
|
514
|
+
raise ValueError(f"profile {n!r} does not exist")
|
|
515
|
+
if _opt_str(section.get("current")) == n:
|
|
516
|
+
section.pop("current", None)
|
|
517
|
+
raw[RC_SECTION] = section
|
|
518
|
+
return _save_raw(raw)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def apply_key_test_metadata(profile_name: Optional[str], data: dict[str, Any]) -> bool:
|
|
522
|
+
"""Cache tenantId / keyName / keyPrefix from a successful key test. Returns whether it wrote."""
|
|
523
|
+
if not profile_name or not isinstance(data, dict):
|
|
524
|
+
return False
|
|
525
|
+
fields = {
|
|
526
|
+
"tenant_id": _opt_str(data.get("tenantId") or data.get("tenant_id")),
|
|
527
|
+
"key_name": _opt_str(data.get("keyName") or data.get("key_name")),
|
|
528
|
+
"key_prefix": _opt_str(data.get("keyPrefix") or data.get("key_prefix")),
|
|
529
|
+
}
|
|
530
|
+
fields = {k: v for k, v in fields.items() if v}
|
|
531
|
+
if not fields:
|
|
532
|
+
return False
|
|
533
|
+
raw, section = _section_for_update()
|
|
534
|
+
target = next((p for p in list_stored_profiles(section) if p.name == profile_name), None)
|
|
535
|
+
if target is None:
|
|
536
|
+
return False
|
|
537
|
+
if target.legacy:
|
|
538
|
+
section.update(fields)
|
|
539
|
+
else:
|
|
540
|
+
table = section.get("profiles")
|
|
541
|
+
if not isinstance(table, dict) or not isinstance(table.get(profile_name), dict):
|
|
542
|
+
return False
|
|
543
|
+
table[profile_name].update(fields)
|
|
544
|
+
raw[RC_SECTION] = section
|
|
545
|
+
_save_raw(raw)
|
|
546
|
+
return True
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def write_rc_key(key: str, value: str) -> Path:
|
|
550
|
+
"""Write a single allowed key into the [openapi] section of ~/.buildifyrc."""
|
|
551
|
+
k = key.strip()
|
|
552
|
+
if k not in RC_KEYS:
|
|
553
|
+
raise ValueError(f"unsupported config key {key!r}; allowed: {', '.join(sorted(RC_KEYS))}")
|
|
554
|
+
if k == "base_url":
|
|
555
|
+
v = normalize_base_url(value)
|
|
556
|
+
if not v:
|
|
557
|
+
raise ValueError("base_url cannot be empty")
|
|
558
|
+
else:
|
|
559
|
+
v = normalize_api_key(value)
|
|
560
|
+
raw, section = _section_for_update()
|
|
561
|
+
current = _opt_str(section.get("current"))
|
|
562
|
+
table = section.get("profiles")
|
|
563
|
+
named = isinstance(table, dict) and current and isinstance(table.get(current), dict)
|
|
564
|
+
if named:
|
|
565
|
+
table[current]["api_key"] = v
|
|
566
|
+
_clear_meta(table[current])
|
|
567
|
+
elif current and current != LEGACY_PROFILE_NAME and not named:
|
|
568
|
+
raise ValueError(f"current profile {current!r} does not exist")
|
|
569
|
+
else:
|
|
570
|
+
section["api_key"] = v
|
|
571
|
+
_clear_meta(section)
|
|
572
|
+
raw[RC_SECTION] = section
|
|
573
|
+
return _save_raw(raw)
|
|
574
|
+
return _write_section({k: v})
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def write_config_file(base_url: Optional[str] = None, api_key: Optional[str] = None) -> Path:
|
|
578
|
+
updates: dict[str, str] = {}
|
|
579
|
+
if base_url is not None:
|
|
580
|
+
updates["base_url"] = normalize_base_url(base_url)
|
|
581
|
+
if api_key is not None:
|
|
582
|
+
updates["api_key"] = normalize_api_key(api_key)
|
|
583
|
+
return _write_section(updates)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def mask_api_key(api_key: str) -> str:
|
|
587
|
+
s = api_key.strip()
|
|
588
|
+
if "." in s:
|
|
589
|
+
kid, _, _rest = s.partition(".")
|
|
590
|
+
return f"{kid}.***"
|
|
591
|
+
if len(s) <= 4:
|
|
592
|
+
return "***"
|
|
593
|
+
return s[:2] + "***" + s[-2:]
|