flower-trellis 0.4.0-beta.5 → 0.4.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/enhancements/0.6/overrides/skills/trellis-finish-work.md +1 -3
- package/enhancements/0.6/overrides/workflow-states/in_progress-inline.md +5 -2
- package/enhancements/0.6/overrides/workflow-states/in_progress.md +4 -1
- package/enhancements/0.6/overrides/workflow-states/no_task.md +1 -1
- package/enhancements/0.6/overrides/workflow-states/planning-inline.md +1 -1
- package/enhancements/0.6/overrides/workflow-states/planning.md +1 -1
- package/enhancements/0.6/overrides/workflow.md +21 -15
- package/enhancements/MANIFEST.json +2 -4
- package/package.json +1 -1
- package/src/lib/skill-catalog.js +0 -1
- package/enhancements/common/.common/.claude/skills/sub2api-account-json-fix/SKILL.md +0 -47
- package/enhancements/common/.common/.claude/skills/sub2api-account-json-fix/env/push.env.example +0 -8
- package/enhancements/common/.common/.claude/skills/sub2api-account-json-fix/scripts/run.sh +0 -27
- package/enhancements/common/.common/.codex/skills/sub2api-account-json-fix/SKILL.md +0 -126
- package/enhancements/common/.common/.codex/skills/sub2api-account-json-fix/agents/openai.yaml +0 -4
- package/enhancements/common/.common/.codex/skills/sub2api-account-json-fix/env/push.env.example +0 -8
- package/enhancements/common/.common/.codex/skills/sub2api-account-json-fix/scripts/fix_exported_account_json.py +0 -890
- package/enhancements/common/.common/.codex/skills/sub2api-account-json-fix/scripts/run.sh +0 -26
|
@@ -1,890 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""按模板补全 sub2api 账号导出 JSON,并可直接推送到 sub2api admin 接口。"""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import argparse
|
|
7
|
-
import copy
|
|
8
|
-
import json
|
|
9
|
-
import os
|
|
10
|
-
import sys
|
|
11
|
-
from dataclasses import dataclass
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
from typing import Any
|
|
14
|
-
from urllib import error as urllib_error
|
|
15
|
-
from urllib import parse as urllib_parse
|
|
16
|
-
from urllib import request as urllib_request
|
|
17
|
-
|
|
18
|
-
SKILL_ROOT = Path(__file__).resolve().parent.parent
|
|
19
|
-
SKILL_PUSH_ENV_FILE = SKILL_ROOT / "env" / "push.env"
|
|
20
|
-
|
|
21
|
-
ENV_PUSH_ENV_FILE_OVERRIDE = "SUB2API_ACCOUNT_JSON_FIX_ENV_FILE"
|
|
22
|
-
ENV_PUSH_ADMIN_BASE_URL = "SUB2API_ACCOUNT_JSON_FIX_PUSH_ADMIN_BASE_URL"
|
|
23
|
-
ENV_PUSH_TOKEN = "SUB2API_ACCOUNT_JSON_FIX_PUSH_TOKEN"
|
|
24
|
-
ENV_PUSH_GROUP_IDS = "SUB2API_ACCOUNT_JSON_FIX_PUSH_GROUP_IDS"
|
|
25
|
-
ENV_PUSH_SCHEDULABLE = "SUB2API_ACCOUNT_JSON_FIX_PUSH_SCHEDULABLE"
|
|
26
|
-
ENV_PUSH_REFRESH_AFTER_CREATE = "SUB2API_ACCOUNT_JSON_FIX_PUSH_REFRESH_AFTER_CREATE"
|
|
27
|
-
|
|
28
|
-
EXAMPLE_PUSH_ADMIN_BASE_URL = "http://127.0.0.1:8080/api/v1/admin"
|
|
29
|
-
EXAMPLE_PUSH_TOKEN = "admin-xxxx"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
@dataclass
|
|
33
|
-
class TemplateValues:
|
|
34
|
-
"""保存从模板账号提取出的补全字段。"""
|
|
35
|
-
|
|
36
|
-
model_mapping: dict[str, Any]
|
|
37
|
-
allow_overages: bool
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
@dataclass
|
|
41
|
-
class FileChangeSummary:
|
|
42
|
-
"""记录单个文件内的修改统计。"""
|
|
43
|
-
|
|
44
|
-
name_fixed: int = 0
|
|
45
|
-
model_mapping_fixed: int = 0
|
|
46
|
-
allow_overages_fixed: int = 0
|
|
47
|
-
|
|
48
|
-
def has_changes(self) -> bool:
|
|
49
|
-
"""判断当前文件是否发生了任何修改。"""
|
|
50
|
-
|
|
51
|
-
return (
|
|
52
|
-
self.name_fixed > 0
|
|
53
|
-
or self.model_mapping_fixed > 0
|
|
54
|
-
or self.allow_overages_fixed > 0
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
@dataclass
|
|
59
|
-
class PushSummary:
|
|
60
|
-
"""记录直推 sub2api 的统计结果。"""
|
|
61
|
-
|
|
62
|
-
success: int = 0
|
|
63
|
-
failed: int = 0
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def parse_args() -> argparse.Namespace:
|
|
67
|
-
"""解析命令行参数。"""
|
|
68
|
-
|
|
69
|
-
parser = argparse.ArgumentParser(
|
|
70
|
-
description="按模板补全 sub2api 账号导出 JSON 的 model_mapping、allow_overages 和 name,并可直接推送到 sub2api。"
|
|
71
|
-
)
|
|
72
|
-
parser.add_argument(
|
|
73
|
-
"--template",
|
|
74
|
-
required=True,
|
|
75
|
-
help="模板 JSON 文件路径,默认取其第一个账号作为模板来源。",
|
|
76
|
-
)
|
|
77
|
-
parser.add_argument(
|
|
78
|
-
"--template-account-index",
|
|
79
|
-
type=int,
|
|
80
|
-
default=0,
|
|
81
|
-
help="模板文件中作为来源账号的索引,默认 0。",
|
|
82
|
-
)
|
|
83
|
-
parser.add_argument(
|
|
84
|
-
"--glob",
|
|
85
|
-
default="sub2api-account-*.json",
|
|
86
|
-
help="未显式传入目标文件时使用的匹配模式,默认 sub2api-account-*.json。",
|
|
87
|
-
)
|
|
88
|
-
mode_group = parser.add_mutually_exclusive_group()
|
|
89
|
-
mode_group.add_argument(
|
|
90
|
-
"--write",
|
|
91
|
-
action="store_true",
|
|
92
|
-
help="真正写回文件。未指定时只预览。",
|
|
93
|
-
)
|
|
94
|
-
mode_group.add_argument(
|
|
95
|
-
"--check",
|
|
96
|
-
action="store_true",
|
|
97
|
-
help="仅检查是否还有待修复项;存在待修复项时退出码为 1。",
|
|
98
|
-
)
|
|
99
|
-
parser.add_argument(
|
|
100
|
-
"--push",
|
|
101
|
-
action="store_true",
|
|
102
|
-
help="把规范化后的账号直接推送到 sub2api admin 接口。",
|
|
103
|
-
)
|
|
104
|
-
parser.add_argument(
|
|
105
|
-
"--push-admin-base-url",
|
|
106
|
-
help="sub2api admin 基础地址,例如 http://127.0.0.1:8080/api/v1/admin。",
|
|
107
|
-
)
|
|
108
|
-
parser.add_argument(
|
|
109
|
-
"--push-token",
|
|
110
|
-
help="sub2api admin token。若以 Bearer 开头则走 Authorization,否则走 x-api-key。",
|
|
111
|
-
)
|
|
112
|
-
parser.add_argument(
|
|
113
|
-
"--push-group-id",
|
|
114
|
-
action="append",
|
|
115
|
-
type=int,
|
|
116
|
-
default=[],
|
|
117
|
-
help="创建账号时绑定到这些 group_id,可重复传入。",
|
|
118
|
-
)
|
|
119
|
-
parser.add_argument(
|
|
120
|
-
"--push-schedulable",
|
|
121
|
-
choices=("true", "false"),
|
|
122
|
-
help="创建账号后是否保持可调度。默认 false,也就是创建后立即关闭调度。",
|
|
123
|
-
)
|
|
124
|
-
parser.add_argument(
|
|
125
|
-
"--push-refresh-after-create",
|
|
126
|
-
choices=("true", "false"),
|
|
127
|
-
help="创建账号后是否立即调用 refresh。默认 true。",
|
|
128
|
-
)
|
|
129
|
-
parser.add_argument(
|
|
130
|
-
"targets",
|
|
131
|
-
nargs="*",
|
|
132
|
-
help="要处理的目标文件;留空时按 --glob 自动搜索。",
|
|
133
|
-
)
|
|
134
|
-
return parser.parse_args()
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def strip_wrapped_quotes(raw_value: str) -> str:
|
|
138
|
-
"""去掉包裹值的同类引号。"""
|
|
139
|
-
|
|
140
|
-
text = str(raw_value or "").strip()
|
|
141
|
-
if len(text) >= 2 and text[0] == text[-1] and text[0] in {"'", '"'}:
|
|
142
|
-
return text[1:-1]
|
|
143
|
-
return text
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def load_env_file(path: Path) -> dict[str, str]:
|
|
147
|
-
"""解析简单的 .env 风格文件。"""
|
|
148
|
-
|
|
149
|
-
if not path.is_file():
|
|
150
|
-
return {}
|
|
151
|
-
|
|
152
|
-
result: dict[str, str] = {}
|
|
153
|
-
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
154
|
-
line = raw_line.strip()
|
|
155
|
-
if not line or line.startswith("#"):
|
|
156
|
-
continue
|
|
157
|
-
if line.startswith("export "):
|
|
158
|
-
line = line[7:].strip()
|
|
159
|
-
key, sep, value = line.partition("=")
|
|
160
|
-
if not sep:
|
|
161
|
-
continue
|
|
162
|
-
normalized_key = key.strip()
|
|
163
|
-
if not normalized_key:
|
|
164
|
-
continue
|
|
165
|
-
result[normalized_key] = strip_wrapped_quotes(value)
|
|
166
|
-
return result
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def parse_group_ids(raw_value: str) -> list[int]:
|
|
170
|
-
"""把逗号分隔的 group_ids 转成整数列表。"""
|
|
171
|
-
|
|
172
|
-
result: list[int] = []
|
|
173
|
-
seen: set[int] = set()
|
|
174
|
-
for chunk in str(raw_value or "").split(","):
|
|
175
|
-
item = chunk.strip()
|
|
176
|
-
if not item:
|
|
177
|
-
continue
|
|
178
|
-
try:
|
|
179
|
-
group_id = int(item)
|
|
180
|
-
except ValueError as exc:
|
|
181
|
-
raise ValueError(f"group_id 不是合法整数: {item}") from exc
|
|
182
|
-
if group_id <= 0:
|
|
183
|
-
raise ValueError(f"group_id 必须大于 0: {item}")
|
|
184
|
-
if group_id not in seen:
|
|
185
|
-
seen.add(group_id)
|
|
186
|
-
result.append(group_id)
|
|
187
|
-
return result
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def resolve_push_env_file() -> Path:
|
|
191
|
-
"""解析当前应该读取的 push.env 路径。"""
|
|
192
|
-
|
|
193
|
-
override = str(os.getenv(ENV_PUSH_ENV_FILE_OVERRIDE, "")).strip()
|
|
194
|
-
if not override:
|
|
195
|
-
return SKILL_PUSH_ENV_FILE
|
|
196
|
-
return Path(override).expanduser().resolve()
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def parse_env_bool(raw_value: str) -> bool:
|
|
200
|
-
"""把环境变量中的布尔字符串转换为布尔值。"""
|
|
201
|
-
|
|
202
|
-
normalized = str(raw_value or "").strip().lower()
|
|
203
|
-
return normalized in {"1", "true", "yes", "on"}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def apply_env_defaults(args: argparse.Namespace) -> argparse.Namespace:
|
|
207
|
-
"""当命令行未显式传值时,使用技能目录配置和环境变量补全直推配置。"""
|
|
208
|
-
|
|
209
|
-
file_env = load_env_file(resolve_push_env_file())
|
|
210
|
-
if not args.push_admin_base_url:
|
|
211
|
-
args.push_admin_base_url = (
|
|
212
|
-
str(file_env.get(ENV_PUSH_ADMIN_BASE_URL, "")).strip()
|
|
213
|
-
or str(os.getenv(ENV_PUSH_ADMIN_BASE_URL, "")).strip()
|
|
214
|
-
or None
|
|
215
|
-
)
|
|
216
|
-
if not args.push_token:
|
|
217
|
-
args.push_token = (
|
|
218
|
-
str(file_env.get(ENV_PUSH_TOKEN, "")).strip()
|
|
219
|
-
or str(os.getenv(ENV_PUSH_TOKEN, "")).strip()
|
|
220
|
-
or None
|
|
221
|
-
)
|
|
222
|
-
if not args.push_group_id:
|
|
223
|
-
raw_group_ids = (
|
|
224
|
-
str(file_env.get(ENV_PUSH_GROUP_IDS, "")).strip()
|
|
225
|
-
or str(os.getenv(ENV_PUSH_GROUP_IDS, "")).strip()
|
|
226
|
-
)
|
|
227
|
-
if raw_group_ids:
|
|
228
|
-
args.push_group_id = parse_group_ids(raw_group_ids)
|
|
229
|
-
if args.push_schedulable is None:
|
|
230
|
-
raw_schedulable = (
|
|
231
|
-
str(file_env.get(ENV_PUSH_SCHEDULABLE, "")).strip()
|
|
232
|
-
or str(os.getenv(ENV_PUSH_SCHEDULABLE, "")).strip()
|
|
233
|
-
)
|
|
234
|
-
if raw_schedulable:
|
|
235
|
-
args.push_schedulable = "true" if parse_env_bool(raw_schedulable) else "false"
|
|
236
|
-
else:
|
|
237
|
-
args.push_schedulable = "false"
|
|
238
|
-
if args.push_refresh_after_create is None:
|
|
239
|
-
raw_refresh_after_create = (
|
|
240
|
-
str(file_env.get(ENV_PUSH_REFRESH_AFTER_CREATE, "")).strip()
|
|
241
|
-
or str(os.getenv(ENV_PUSH_REFRESH_AFTER_CREATE, "")).strip()
|
|
242
|
-
)
|
|
243
|
-
if raw_refresh_after_create:
|
|
244
|
-
args.push_refresh_after_create = "true" if parse_env_bool(raw_refresh_after_create) else "false"
|
|
245
|
-
else:
|
|
246
|
-
args.push_refresh_after_create = "true"
|
|
247
|
-
return args
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
def is_placeholder_push_config(args: argparse.Namespace) -> bool:
|
|
251
|
-
"""判断当前直推配置是否仍然是示例占位值。"""
|
|
252
|
-
|
|
253
|
-
admin_base_url = str(args.push_admin_base_url or "").strip()
|
|
254
|
-
token = str(args.push_token or "").strip()
|
|
255
|
-
if token == EXAMPLE_PUSH_TOKEN:
|
|
256
|
-
return True
|
|
257
|
-
return admin_base_url == EXAMPLE_PUSH_ADMIN_BASE_URL and token == EXAMPLE_PUSH_TOKEN
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
def load_json(path: Path) -> Any:
|
|
261
|
-
"""读取并解析 JSON 文件。"""
|
|
262
|
-
|
|
263
|
-
with path.open("r", encoding="utf-8") as handle:
|
|
264
|
-
return json.load(handle)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
def write_json(path: Path, payload: Any) -> None:
|
|
268
|
-
"""以统一格式写回 JSON 文件。"""
|
|
269
|
-
|
|
270
|
-
text = json.dumps(payload, ensure_ascii=False, indent=2)
|
|
271
|
-
path.write_text(text + "\n", encoding="utf-8")
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
def build_target_paths(args: argparse.Namespace, template_path: Path) -> list[Path]:
|
|
275
|
-
"""构建要处理的目标文件列表,并自动跳过模板文件。"""
|
|
276
|
-
|
|
277
|
-
if args.targets:
|
|
278
|
-
raw_paths = [Path(item) for item in args.targets]
|
|
279
|
-
else:
|
|
280
|
-
raw_paths = sorted(Path.cwd().glob(args.glob))
|
|
281
|
-
|
|
282
|
-
seen: set[Path] = set()
|
|
283
|
-
result: list[Path] = []
|
|
284
|
-
template_resolved = template_path.resolve()
|
|
285
|
-
for raw_path in raw_paths:
|
|
286
|
-
resolved = raw_path.resolve()
|
|
287
|
-
if resolved == template_resolved:
|
|
288
|
-
continue
|
|
289
|
-
if resolved in seen:
|
|
290
|
-
continue
|
|
291
|
-
seen.add(resolved)
|
|
292
|
-
result.append(raw_path)
|
|
293
|
-
return result
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
def extract_template_values(template_data: dict[str, Any], account_index: int) -> TemplateValues:
|
|
297
|
-
"""从模板文件中提取补全时要使用的字段。"""
|
|
298
|
-
|
|
299
|
-
accounts = template_data.get("accounts")
|
|
300
|
-
if not isinstance(accounts, list) or not accounts:
|
|
301
|
-
raise ValueError("模板文件缺少非空的 accounts 数组")
|
|
302
|
-
if account_index < 0 or account_index >= len(accounts):
|
|
303
|
-
raise ValueError(f"模板账号索引越界: {account_index}")
|
|
304
|
-
|
|
305
|
-
template_account = accounts[account_index]
|
|
306
|
-
if not isinstance(template_account, dict):
|
|
307
|
-
raise ValueError("模板账号结构无效")
|
|
308
|
-
|
|
309
|
-
credentials = template_account.get("credentials")
|
|
310
|
-
if not isinstance(credentials, dict):
|
|
311
|
-
raise ValueError("模板账号缺少 credentials 对象")
|
|
312
|
-
|
|
313
|
-
model_mapping = credentials.get("model_mapping")
|
|
314
|
-
if not isinstance(model_mapping, dict) or not model_mapping:
|
|
315
|
-
raise ValueError("模板账号缺少有效的 credentials.model_mapping")
|
|
316
|
-
|
|
317
|
-
extra = template_account.get("extra")
|
|
318
|
-
if not isinstance(extra, dict):
|
|
319
|
-
raise ValueError("模板账号缺少 extra 对象")
|
|
320
|
-
|
|
321
|
-
allow_overages = extra.get("allow_overages")
|
|
322
|
-
if not isinstance(allow_overages, bool):
|
|
323
|
-
raise ValueError("模板账号缺少布尔类型的 extra.allow_overages")
|
|
324
|
-
|
|
325
|
-
return TemplateValues(
|
|
326
|
-
model_mapping=copy.deepcopy(model_mapping),
|
|
327
|
-
allow_overages=allow_overages,
|
|
328
|
-
)
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
def replace_or_insert_key(
|
|
332
|
-
payload: dict[str, Any],
|
|
333
|
-
key: str,
|
|
334
|
-
value: Any,
|
|
335
|
-
*,
|
|
336
|
-
insert_after: tuple[str, ...] = (),
|
|
337
|
-
) -> tuple[dict[str, Any], bool]:
|
|
338
|
-
"""替换已有键或按指定相对位置插入新键。"""
|
|
339
|
-
|
|
340
|
-
if key in payload:
|
|
341
|
-
if payload[key] == value:
|
|
342
|
-
return payload, False
|
|
343
|
-
updated: dict[str, Any] = {}
|
|
344
|
-
for existing_key, existing_value in payload.items():
|
|
345
|
-
if existing_key == key:
|
|
346
|
-
updated[existing_key] = copy.deepcopy(value)
|
|
347
|
-
else:
|
|
348
|
-
updated[existing_key] = existing_value
|
|
349
|
-
return updated, True
|
|
350
|
-
|
|
351
|
-
updated = {}
|
|
352
|
-
inserted = False
|
|
353
|
-
for existing_key, existing_value in payload.items():
|
|
354
|
-
updated[existing_key] = existing_value
|
|
355
|
-
if not inserted and existing_key in insert_after:
|
|
356
|
-
updated[key] = copy.deepcopy(value)
|
|
357
|
-
inserted = True
|
|
358
|
-
if not inserted:
|
|
359
|
-
updated[key] = copy.deepcopy(value)
|
|
360
|
-
return updated, True
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
def normalize_account(
|
|
364
|
-
account: dict[str, Any],
|
|
365
|
-
template_values: TemplateValues,
|
|
366
|
-
) -> FileChangeSummary:
|
|
367
|
-
"""补全单个账号对象并返回修改统计。"""
|
|
368
|
-
|
|
369
|
-
summary = FileChangeSummary()
|
|
370
|
-
|
|
371
|
-
credentials = account.get("credentials")
|
|
372
|
-
if not isinstance(credentials, dict):
|
|
373
|
-
raise ValueError("账号缺少 credentials 对象")
|
|
374
|
-
|
|
375
|
-
updated_credentials, changed = replace_or_insert_key(
|
|
376
|
-
credentials,
|
|
377
|
-
"model_mapping",
|
|
378
|
-
template_values.model_mapping,
|
|
379
|
-
insert_after=("expires_at", "email"),
|
|
380
|
-
)
|
|
381
|
-
if changed:
|
|
382
|
-
account["credentials"] = updated_credentials
|
|
383
|
-
credentials = updated_credentials
|
|
384
|
-
summary.model_mapping_fixed += 1
|
|
385
|
-
|
|
386
|
-
email = credentials.get("email")
|
|
387
|
-
if isinstance(email, str):
|
|
388
|
-
normalized_email = email.strip()
|
|
389
|
-
if normalized_email and account.get("name") != normalized_email:
|
|
390
|
-
account["name"] = normalized_email
|
|
391
|
-
summary.name_fixed += 1
|
|
392
|
-
|
|
393
|
-
extra = account.get("extra")
|
|
394
|
-
if extra is None:
|
|
395
|
-
extra = {}
|
|
396
|
-
if not isinstance(extra, dict):
|
|
397
|
-
raise ValueError("账号 extra 不是对象")
|
|
398
|
-
|
|
399
|
-
updated_extra, changed = replace_or_insert_key(
|
|
400
|
-
extra,
|
|
401
|
-
"allow_overages",
|
|
402
|
-
template_values.allow_overages,
|
|
403
|
-
)
|
|
404
|
-
if changed or account.get("extra") is None:
|
|
405
|
-
account["extra"] = updated_extra
|
|
406
|
-
summary.allow_overages_fixed += 1
|
|
407
|
-
|
|
408
|
-
return summary
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
def normalize_file(path: Path, template_values: TemplateValues) -> tuple[dict[str, Any], FileChangeSummary]:
|
|
412
|
-
"""补全单个 JSON 文件中的所有账号。"""
|
|
413
|
-
|
|
414
|
-
payload = load_json(path)
|
|
415
|
-
if not isinstance(payload, dict):
|
|
416
|
-
raise ValueError("JSON 根节点必须是对象")
|
|
417
|
-
|
|
418
|
-
accounts = payload.get("accounts")
|
|
419
|
-
if not isinstance(accounts, list):
|
|
420
|
-
raise ValueError("JSON 缺少 accounts 数组")
|
|
421
|
-
|
|
422
|
-
total = FileChangeSummary()
|
|
423
|
-
for account in accounts:
|
|
424
|
-
if not isinstance(account, dict):
|
|
425
|
-
raise ValueError("accounts 中存在非对象元素")
|
|
426
|
-
item_summary = normalize_account(account, template_values)
|
|
427
|
-
total.name_fixed += item_summary.name_fixed
|
|
428
|
-
total.model_mapping_fixed += item_summary.model_mapping_fixed
|
|
429
|
-
total.allow_overages_fixed += item_summary.allow_overages_fixed
|
|
430
|
-
|
|
431
|
-
return payload, total
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
def print_summary(path: Path, summary: FileChangeSummary, *, action: str) -> None:
|
|
435
|
-
"""打印单个文件的处理摘要。"""
|
|
436
|
-
|
|
437
|
-
print(
|
|
438
|
-
f"[{action}] {path}: "
|
|
439
|
-
f"name={summary.name_fixed}, "
|
|
440
|
-
f"model_mapping={summary.model_mapping_fixed}, "
|
|
441
|
-
f"allow_overages={summary.allow_overages_fixed}"
|
|
442
|
-
)
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
class Sub2APIClient:
|
|
446
|
-
"""简易 sub2api admin API 客户端。"""
|
|
447
|
-
|
|
448
|
-
def __init__(self, admin_base_url: str, token: str) -> None:
|
|
449
|
-
self.admin_base_url = self.normalize_admin_base_url(admin_base_url)
|
|
450
|
-
self.token = str(token or "").strip()
|
|
451
|
-
|
|
452
|
-
@staticmethod
|
|
453
|
-
def normalize_admin_base_url(admin_base_url: str) -> str:
|
|
454
|
-
"""兼容传入 admin 根路径或具体 accounts/proxies 路径。"""
|
|
455
|
-
|
|
456
|
-
url = str(admin_base_url or "").rstrip("/")
|
|
457
|
-
for suffix in ("/accounts", "/proxies"):
|
|
458
|
-
if url.endswith(suffix):
|
|
459
|
-
return url[: -len(suffix)]
|
|
460
|
-
return url
|
|
461
|
-
|
|
462
|
-
def request(
|
|
463
|
-
self,
|
|
464
|
-
method: str,
|
|
465
|
-
path: str,
|
|
466
|
-
*,
|
|
467
|
-
payload: Any | None = None,
|
|
468
|
-
query: dict[str, Any] | None = None,
|
|
469
|
-
) -> Any:
|
|
470
|
-
"""发起一次 JSON 请求并返回解包后的 data。"""
|
|
471
|
-
|
|
472
|
-
url = self.admin_base_url + path
|
|
473
|
-
if query:
|
|
474
|
-
normalized_query = {
|
|
475
|
-
key: value
|
|
476
|
-
for key, value in query.items()
|
|
477
|
-
if value is not None and value != ""
|
|
478
|
-
}
|
|
479
|
-
if normalized_query:
|
|
480
|
-
url += "?" + urllib_parse.urlencode(normalized_query)
|
|
481
|
-
|
|
482
|
-
headers = {
|
|
483
|
-
"Accept": "application/json, text/plain, */*",
|
|
484
|
-
}
|
|
485
|
-
if payload is not None:
|
|
486
|
-
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
487
|
-
headers["Content-Type"] = "application/json"
|
|
488
|
-
else:
|
|
489
|
-
body = None
|
|
490
|
-
|
|
491
|
-
if self.token.lower().startswith("bearer "):
|
|
492
|
-
headers["Authorization"] = self.token
|
|
493
|
-
else:
|
|
494
|
-
headers["x-api-key"] = self.token
|
|
495
|
-
|
|
496
|
-
request = urllib_request.Request(url=url, data=body, headers=headers, method=method.upper())
|
|
497
|
-
try:
|
|
498
|
-
with urllib_request.urlopen(request, timeout=30) as response:
|
|
499
|
-
raw = response.read().decode("utf-8")
|
|
500
|
-
return self._unwrap_response(raw)
|
|
501
|
-
except urllib_error.HTTPError as exc:
|
|
502
|
-
error_body = exc.read().decode("utf-8", errors="replace")
|
|
503
|
-
raise RuntimeError(self._format_error(exc.code, error_body)) from exc
|
|
504
|
-
except urllib_error.URLError as exc:
|
|
505
|
-
raise RuntimeError(f"请求失败: {exc.reason}") from exc
|
|
506
|
-
|
|
507
|
-
@staticmethod
|
|
508
|
-
def _unwrap_response(raw_text: str) -> Any:
|
|
509
|
-
"""解包 sub2api 标准响应格式。"""
|
|
510
|
-
|
|
511
|
-
if not raw_text.strip():
|
|
512
|
-
return None
|
|
513
|
-
payload = json.loads(raw_text)
|
|
514
|
-
if isinstance(payload, dict) and "code" in payload:
|
|
515
|
-
if payload.get("code") == 0:
|
|
516
|
-
return payload.get("data")
|
|
517
|
-
raise RuntimeError(str(payload.get("message", "接口返回错误")))
|
|
518
|
-
return payload
|
|
519
|
-
|
|
520
|
-
@staticmethod
|
|
521
|
-
def _format_error(status_code: int, raw_text: str) -> str:
|
|
522
|
-
"""把 HTTP 错误响应整理成可读文本。"""
|
|
523
|
-
|
|
524
|
-
try:
|
|
525
|
-
payload = json.loads(raw_text)
|
|
526
|
-
except json.JSONDecodeError:
|
|
527
|
-
payload = None
|
|
528
|
-
|
|
529
|
-
if isinstance(payload, dict):
|
|
530
|
-
message = payload.get("message")
|
|
531
|
-
reason = payload.get("reason")
|
|
532
|
-
if message and reason:
|
|
533
|
-
return f"HTTP {status_code}: {message} ({reason})"
|
|
534
|
-
if message:
|
|
535
|
-
return f"HTTP {status_code}: {message}"
|
|
536
|
-
compact = raw_text.strip().replace("\n", " ")
|
|
537
|
-
if compact:
|
|
538
|
-
return f"HTTP {status_code}: {compact[:300]}"
|
|
539
|
-
return f"HTTP {status_code}"
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
def validate_push_args(args: argparse.Namespace) -> None:
|
|
543
|
-
"""校验直推模式必需参数。"""
|
|
544
|
-
|
|
545
|
-
if not args.push:
|
|
546
|
-
return
|
|
547
|
-
push_env_file = resolve_push_env_file()
|
|
548
|
-
if not args.push_admin_base_url:
|
|
549
|
-
raise ValueError(
|
|
550
|
-
"启用 --push 时必须提供 --push-admin-base-url,或在技能目录配置 "
|
|
551
|
-
f"{push_env_file},或设置环境变量 {ENV_PUSH_ADMIN_BASE_URL}"
|
|
552
|
-
)
|
|
553
|
-
if not args.push_token:
|
|
554
|
-
raise ValueError(
|
|
555
|
-
"启用 --push 时必须提供 --push-token,或在技能目录配置 "
|
|
556
|
-
f"{push_env_file},或设置环境变量 {ENV_PUSH_TOKEN}"
|
|
557
|
-
)
|
|
558
|
-
if is_placeholder_push_config(args):
|
|
559
|
-
raise ValueError(
|
|
560
|
-
"检测到直推配置仍是示例占位值,请先让用户提供真实的 sub2api admin 地址和 token,"
|
|
561
|
-
f"再更新 {push_env_file} 或命令行参数后重试"
|
|
562
|
-
)
|
|
563
|
-
for group_id in args.push_group_id:
|
|
564
|
-
if group_id <= 0:
|
|
565
|
-
raise ValueError(f"group_id 必须大于 0: {group_id}")
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
def build_proxy_key(protocol: str, host: str, port: int, username: str, password: str) -> str:
|
|
569
|
-
"""按 sub2api 的规则生成 proxy_key。"""
|
|
570
|
-
|
|
571
|
-
return "|".join(
|
|
572
|
-
[
|
|
573
|
-
str(protocol or "").strip(),
|
|
574
|
-
str(host or "").strip(),
|
|
575
|
-
str(port),
|
|
576
|
-
str(username or "").strip(),
|
|
577
|
-
str(password or "").strip(),
|
|
578
|
-
]
|
|
579
|
-
)
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
def build_proxy_key_from_item(item: dict[str, Any]) -> str:
|
|
583
|
-
"""从代理对象中提取或构造 proxy_key。"""
|
|
584
|
-
|
|
585
|
-
proxy_key = str(item.get("proxy_key", "") or "").strip()
|
|
586
|
-
if proxy_key:
|
|
587
|
-
return proxy_key
|
|
588
|
-
return build_proxy_key(
|
|
589
|
-
str(item.get("protocol", "") or ""),
|
|
590
|
-
str(item.get("host", "") or ""),
|
|
591
|
-
int(item.get("port", 0) or 0),
|
|
592
|
-
str(item.get("username", "") or ""),
|
|
593
|
-
str(item.get("password", "") or ""),
|
|
594
|
-
)
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
def list_remote_proxies(client: Sub2APIClient) -> dict[str, dict[str, Any]]:
|
|
598
|
-
"""读取远端全部代理,并按 proxy_key 建立索引。"""
|
|
599
|
-
|
|
600
|
-
page = 1
|
|
601
|
-
page_size = 1000
|
|
602
|
-
result: dict[str, dict[str, Any]] = {}
|
|
603
|
-
while True:
|
|
604
|
-
payload = client.request(
|
|
605
|
-
"GET",
|
|
606
|
-
"/proxies",
|
|
607
|
-
query={"page": page, "page_size": page_size},
|
|
608
|
-
)
|
|
609
|
-
if not isinstance(payload, dict):
|
|
610
|
-
raise RuntimeError("代理列表响应格式不正确")
|
|
611
|
-
items = payload.get("items")
|
|
612
|
-
if not isinstance(items, list):
|
|
613
|
-
raise RuntimeError("代理列表缺少 items")
|
|
614
|
-
for item in items:
|
|
615
|
-
if not isinstance(item, dict):
|
|
616
|
-
continue
|
|
617
|
-
key = build_proxy_key(
|
|
618
|
-
str(item.get("protocol", "") or ""),
|
|
619
|
-
str(item.get("host", "") or ""),
|
|
620
|
-
int(item.get("port", 0) or 0),
|
|
621
|
-
str(item.get("username", "") or ""),
|
|
622
|
-
str(item.get("password", "") or ""),
|
|
623
|
-
)
|
|
624
|
-
result[key] = item
|
|
625
|
-
total = int(payload.get("total", 0) or 0)
|
|
626
|
-
if len(items) == 0 or page * page_size >= total:
|
|
627
|
-
break
|
|
628
|
-
page += 1
|
|
629
|
-
return result
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
def ensure_remote_proxy(
|
|
633
|
-
client: Sub2APIClient,
|
|
634
|
-
remote_proxies: dict[str, dict[str, Any]],
|
|
635
|
-
item: dict[str, Any],
|
|
636
|
-
) -> int:
|
|
637
|
-
"""确保远端存在目标代理,并返回 proxy_id。"""
|
|
638
|
-
|
|
639
|
-
key = build_proxy_key_from_item(item)
|
|
640
|
-
existing = remote_proxies.get(key)
|
|
641
|
-
desired_status = str(item.get("status", "") or "").strip().lower()
|
|
642
|
-
if existing is None:
|
|
643
|
-
created = client.request(
|
|
644
|
-
"POST",
|
|
645
|
-
"/proxies",
|
|
646
|
-
payload={
|
|
647
|
-
"name": str(item.get("name", "") or "imported-proxy"),
|
|
648
|
-
"protocol": str(item.get("protocol", "") or "").strip(),
|
|
649
|
-
"host": str(item.get("host", "") or "").strip(),
|
|
650
|
-
"port": int(item.get("port", 0) or 0),
|
|
651
|
-
"username": str(item.get("username", "") or "").strip(),
|
|
652
|
-
"password": str(item.get("password", "") or "").strip(),
|
|
653
|
-
},
|
|
654
|
-
)
|
|
655
|
-
if not isinstance(created, dict):
|
|
656
|
-
raise RuntimeError("创建代理返回格式不正确")
|
|
657
|
-
existing = created
|
|
658
|
-
remote_proxies[key] = existing
|
|
659
|
-
|
|
660
|
-
proxy_id = int(existing.get("id", 0) or 0)
|
|
661
|
-
if proxy_id <= 0:
|
|
662
|
-
raise RuntimeError("远端代理缺少有效 id")
|
|
663
|
-
|
|
664
|
-
current_status = str(existing.get("status", "") or "").strip().lower()
|
|
665
|
-
if desired_status in {"active", "inactive"} and current_status != desired_status:
|
|
666
|
-
updated = client.request(
|
|
667
|
-
"PUT",
|
|
668
|
-
f"/proxies/{proxy_id}",
|
|
669
|
-
payload={"status": desired_status},
|
|
670
|
-
)
|
|
671
|
-
if isinstance(updated, dict):
|
|
672
|
-
remote_proxies[key] = updated
|
|
673
|
-
return proxy_id
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
def build_create_account_payload(
|
|
677
|
-
account: dict[str, Any],
|
|
678
|
-
proxy_id: int | None,
|
|
679
|
-
group_ids: list[int],
|
|
680
|
-
) -> dict[str, Any]:
|
|
681
|
-
"""把导出 JSON 中的账号对象转换为创建接口所需 payload。"""
|
|
682
|
-
|
|
683
|
-
payload = {
|
|
684
|
-
"name": account.get("name"),
|
|
685
|
-
"notes": account.get("notes"),
|
|
686
|
-
"platform": account.get("platform"),
|
|
687
|
-
"type": account.get("type"),
|
|
688
|
-
"credentials": account.get("credentials"),
|
|
689
|
-
"extra": account.get("extra") or {},
|
|
690
|
-
"proxy_id": proxy_id,
|
|
691
|
-
"concurrency": int(account.get("concurrency", 0) or 0),
|
|
692
|
-
"priority": int(account.get("priority", 0) or 0),
|
|
693
|
-
"rate_multiplier": account.get("rate_multiplier"),
|
|
694
|
-
"expires_at": account.get("expires_at"),
|
|
695
|
-
"auto_pause_on_expired": account.get("auto_pause_on_expired", True),
|
|
696
|
-
}
|
|
697
|
-
if group_ids:
|
|
698
|
-
payload["group_ids"] = group_ids
|
|
699
|
-
return payload
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
def set_account_schedulable(client: Sub2APIClient, account_id: int, schedulable: bool) -> None:
|
|
703
|
-
"""创建账号后显式设置调度开关。"""
|
|
704
|
-
|
|
705
|
-
client.request(
|
|
706
|
-
"POST",
|
|
707
|
-
f"/accounts/{account_id}/schedulable",
|
|
708
|
-
payload={"schedulable": schedulable},
|
|
709
|
-
)
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
def should_refresh_after_create(account: dict[str, Any], refresh_after_create: bool) -> bool:
|
|
713
|
-
"""判断当前账号创建后是否应触发 refresh。"""
|
|
714
|
-
|
|
715
|
-
if not refresh_after_create:
|
|
716
|
-
return False
|
|
717
|
-
if str(account.get("type", "") or "").strip() != "oauth":
|
|
718
|
-
return False
|
|
719
|
-
credentials = account.get("credentials")
|
|
720
|
-
if not isinstance(credentials, dict):
|
|
721
|
-
return False
|
|
722
|
-
refresh_token = str(credentials.get("refresh_token", "") or "").strip()
|
|
723
|
-
return refresh_token != ""
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
def refresh_account_after_create(client: Sub2APIClient, account_id: int) -> None:
|
|
727
|
-
"""创建账号后触发一次远端 refresh。"""
|
|
728
|
-
|
|
729
|
-
client.request("POST", f"/accounts/{account_id}/refresh", payload={})
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
def push_files_by_create(
|
|
733
|
-
client: Sub2APIClient,
|
|
734
|
-
prepared_files: list[tuple[Path, dict[str, Any], FileChangeSummary]],
|
|
735
|
-
args: argparse.Namespace,
|
|
736
|
-
) -> PushSummary:
|
|
737
|
-
"""通过 `/accounts` 逐个创建账号到 sub2api。"""
|
|
738
|
-
|
|
739
|
-
summary = PushSummary()
|
|
740
|
-
remote_proxies = list_remote_proxies(client)
|
|
741
|
-
target_schedulable = str(args.push_schedulable or "false").lower() == "true"
|
|
742
|
-
refresh_after_create = str(args.push_refresh_after_create or "true").lower() == "true"
|
|
743
|
-
|
|
744
|
-
for path, payload, _ in prepared_files:
|
|
745
|
-
proxies = payload.get("proxies")
|
|
746
|
-
accounts = payload.get("accounts")
|
|
747
|
-
if not isinstance(proxies, list) or not isinstance(accounts, list):
|
|
748
|
-
print(f"[错误] {path}: 不是合法的 sub2api bundle JSON", file=sys.stderr)
|
|
749
|
-
summary.failed += 1
|
|
750
|
-
continue
|
|
751
|
-
|
|
752
|
-
proxy_id_by_key: dict[str, int] = {}
|
|
753
|
-
for item in proxies:
|
|
754
|
-
if not isinstance(item, dict):
|
|
755
|
-
continue
|
|
756
|
-
try:
|
|
757
|
-
proxy_id_by_key[build_proxy_key_from_item(item)] = ensure_remote_proxy(
|
|
758
|
-
client, remote_proxies, item
|
|
759
|
-
)
|
|
760
|
-
except Exception as exc: # noqa: BLE001
|
|
761
|
-
summary.failed += 1
|
|
762
|
-
print(
|
|
763
|
-
f"[错误] {path}: 代理 {item.get('name') or item.get('host') or '-'} 推送失败: {exc}",
|
|
764
|
-
file=sys.stderr,
|
|
765
|
-
)
|
|
766
|
-
|
|
767
|
-
for account in accounts:
|
|
768
|
-
if not isinstance(account, dict):
|
|
769
|
-
summary.failed += 1
|
|
770
|
-
print(f"[错误] {path}: accounts 中存在非法元素", file=sys.stderr)
|
|
771
|
-
continue
|
|
772
|
-
|
|
773
|
-
try:
|
|
774
|
-
proxy_key = str(account.get("proxy_key", "") or "").strip()
|
|
775
|
-
proxy_id = proxy_id_by_key.get(proxy_key) if proxy_key else None
|
|
776
|
-
if proxy_key and proxy_id is None:
|
|
777
|
-
raise RuntimeError(f"找不到对应代理: {proxy_key}")
|
|
778
|
-
created = client.request(
|
|
779
|
-
"POST",
|
|
780
|
-
"/accounts",
|
|
781
|
-
payload=build_create_account_payload(account, proxy_id, args.push_group_id),
|
|
782
|
-
)
|
|
783
|
-
if not isinstance(created, dict):
|
|
784
|
-
raise RuntimeError("创建账号返回格式不正确")
|
|
785
|
-
account_id = int(created.get("id", 0) or 0)
|
|
786
|
-
if account_id <= 0:
|
|
787
|
-
raise RuntimeError("创建账号成功但返回中缺少有效 id")
|
|
788
|
-
set_account_schedulable(client, account_id, target_schedulable)
|
|
789
|
-
refresh_status = "skipped"
|
|
790
|
-
if should_refresh_after_create(account, refresh_after_create):
|
|
791
|
-
refresh_account_after_create(client, account_id)
|
|
792
|
-
refresh_status = "triggered"
|
|
793
|
-
summary.success += 1
|
|
794
|
-
group_text = ",".join(str(item) for item in args.push_group_id) if args.push_group_id else "-"
|
|
795
|
-
print(
|
|
796
|
-
f"[直推/create] {path}: "
|
|
797
|
-
f"account_id={account_id}, "
|
|
798
|
-
f"name={created.get('name', account.get('name', ''))}, "
|
|
799
|
-
f"group_ids={group_text}, "
|
|
800
|
-
f"schedulable={str(target_schedulable).lower()}, "
|
|
801
|
-
f"refresh={refresh_status}"
|
|
802
|
-
)
|
|
803
|
-
except Exception as exc: # noqa: BLE001
|
|
804
|
-
summary.failed += 1
|
|
805
|
-
print(
|
|
806
|
-
f"[错误] {path}: 账号 {account.get('name') or '-'} 推送失败: {exc}",
|
|
807
|
-
file=sys.stderr,
|
|
808
|
-
)
|
|
809
|
-
|
|
810
|
-
return summary
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
def push_prepared_files(
|
|
814
|
-
args: argparse.Namespace,
|
|
815
|
-
prepared_files: list[tuple[Path, dict[str, Any], FileChangeSummary]],
|
|
816
|
-
) -> PushSummary:
|
|
817
|
-
"""把已规范化的数据推送到 sub2api。"""
|
|
818
|
-
|
|
819
|
-
validate_push_args(args)
|
|
820
|
-
client = Sub2APIClient(args.push_admin_base_url, args.push_token)
|
|
821
|
-
return push_files_by_create(client, prepared_files, args)
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
def main() -> int:
|
|
825
|
-
"""执行入口。"""
|
|
826
|
-
|
|
827
|
-
args = apply_env_defaults(parse_args())
|
|
828
|
-
template_path = Path(args.template)
|
|
829
|
-
if not template_path.is_file():
|
|
830
|
-
print(f"模板文件不存在: {template_path}", file=sys.stderr)
|
|
831
|
-
return 2
|
|
832
|
-
|
|
833
|
-
try:
|
|
834
|
-
template_data = load_json(template_path)
|
|
835
|
-
if not isinstance(template_data, dict):
|
|
836
|
-
raise ValueError("模板 JSON 根节点必须是对象")
|
|
837
|
-
template_values = extract_template_values(template_data, args.template_account_index)
|
|
838
|
-
except Exception as exc: # noqa: BLE001
|
|
839
|
-
print(f"读取模板失败: {exc}", file=sys.stderr)
|
|
840
|
-
return 2
|
|
841
|
-
|
|
842
|
-
target_paths = build_target_paths(args, template_path)
|
|
843
|
-
if not target_paths:
|
|
844
|
-
print("没有找到需要处理的目标文件", file=sys.stderr)
|
|
845
|
-
return 2
|
|
846
|
-
|
|
847
|
-
changed_files = 0
|
|
848
|
-
had_error = False
|
|
849
|
-
action = "写入" if args.write else "预览"
|
|
850
|
-
prepared_files: list[tuple[Path, dict[str, Any], FileChangeSummary]] = []
|
|
851
|
-
|
|
852
|
-
for path in target_paths:
|
|
853
|
-
try:
|
|
854
|
-
payload, summary = normalize_file(path, template_values)
|
|
855
|
-
except Exception as exc: # noqa: BLE001
|
|
856
|
-
had_error = True
|
|
857
|
-
print(f"[错误] {path}: {exc}", file=sys.stderr)
|
|
858
|
-
continue
|
|
859
|
-
|
|
860
|
-
if not summary.has_changes():
|
|
861
|
-
print_summary(path, summary, action="跳过")
|
|
862
|
-
else:
|
|
863
|
-
changed_files += 1
|
|
864
|
-
if args.write:
|
|
865
|
-
write_json(path, payload)
|
|
866
|
-
print_summary(path, summary, action=action)
|
|
867
|
-
prepared_files.append((path, payload, summary))
|
|
868
|
-
|
|
869
|
-
print(f"总目标文件: {len(target_paths)}")
|
|
870
|
-
print(f"发生修改的文件: {changed_files}")
|
|
871
|
-
|
|
872
|
-
if had_error:
|
|
873
|
-
return 2
|
|
874
|
-
if args.push:
|
|
875
|
-
try:
|
|
876
|
-
push_summary = push_prepared_files(args, prepared_files)
|
|
877
|
-
except Exception as exc: # noqa: BLE001
|
|
878
|
-
print(f"直推失败: {exc}", file=sys.stderr)
|
|
879
|
-
return 2
|
|
880
|
-
print(f"直推成功: {push_summary.success}")
|
|
881
|
-
print(f"直推失败: {push_summary.failed}")
|
|
882
|
-
if push_summary.failed > 0:
|
|
883
|
-
return 2
|
|
884
|
-
if args.check and changed_files > 0:
|
|
885
|
-
return 1
|
|
886
|
-
return 0
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
if __name__ == "__main__":
|
|
890
|
-
sys.exit(main())
|