draftgo-cli 3.0.35 → 3.0.38
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 +220 -272
- package/package.json +6 -2
- package/resources/skill/SKILL.md +114 -55
- package/resources/skill/init/SKILL.md +29 -15
- package/resources/skill/manifest.json +5 -4
- package/resources/skill/push/SKILL.md +41 -29
- package/resources/skill/references/aihub.md +8 -5
- package/resources/skill/references/api-endpoints.md +5 -3
- package/resources/skill/references/architecture.md +1 -1
- package/resources/skill/references/checkout.md +116 -0
- package/resources/skill/references/custom-services.md +9 -10
- package/resources/skill/references/data.md +4 -2
- package/resources/skill/references/frontend.md +1 -1
- package/resources/skill/references/mcp.md +101 -0
- package/resources/skill/references/modules.md +8 -8
- package/resources/skill/references/parallel.md +6 -3
- package/resources/skill/references/runtime.md +7 -10
- package/resources/skill/scripts/README.md +8 -0
- package/resources/skill/story/SKILL.md +8 -8
- package/src/cli.js +5 -0
- package/src/commandRegistry.js +7 -1
- package/src/commands/api.js +24 -187
- package/src/commands/autoPush.js +48 -17
- package/src/commands/check.js +17 -47
- package/src/commands/checkout.js +18 -0
- package/src/commands/commit.js +21 -0
- package/src/commands/conflict.js +30 -0
- package/src/commands/conflicts.js +16 -0
- package/src/commands/connect.js +60 -48
- package/src/commands/delete.js +79 -64
- package/src/commands/deploy.js +18 -10
- package/src/commands/diff.js +23 -0
- package/src/commands/help.js +99 -75
- package/src/commands/init.js +4 -10
- package/src/commands/local.js +23 -6
- package/src/commands/map.js +89 -89
- package/src/commands/mcp.js +126 -0
- package/src/commands/sync.js +28 -43
- package/src/commands/verifyUi.js +3 -2
- package/src/localdev/index.js +37 -7
- package/src/localdev/mysqlClient.js +1 -1
- package/src/mcp/client.js +275 -0
- package/src/mcp/hosts.js +520 -0
- package/src/mcp/protocol.js +173 -0
- package/src/mcp/stdio.js +300 -0
- package/src/mcp/tools.js +37 -0
- package/src/platforms.js +3 -4
- package/src/projectConfig.js +91 -49
- package/src/projectMap.js +123 -460
- package/src/skill.js +6 -28
- package/src/worktree/backend.js +250 -0
- package/src/worktree/errors.js +28 -0
- package/src/worktree/index.js +461 -0
- package/src/worktree/manifest.js +75 -0
- package/src/worktree/streams.js +200 -0
- package/src/worktree/types.js +103 -0
- package/src/worktree/validate.js +37 -0
- package/resources/skill/pull/SKILL.md +0 -33
- package/resources/skill/references/api.json +0 -20248
- package/resources/skill/scripts/draftgo_delete.py +0 -149
- package/resources/skill/scripts/draftgo_init.py +0 -80
- package/resources/skill/scripts/draftgo_pull.py +0 -427
- package/resources/skill/scripts/draftgo_push.py +0 -1022
- package/src/python.js +0 -27
|
@@ -1,1022 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
DraftGo Push Script
|
|
4
|
-
推送本地修改到云端。覆盖所有 init 拉取的类型:
|
|
5
|
-
pages / nav / db_meta / aihub / system_config / roles / users
|
|
6
|
-
docs / doc_categories / custom_scripts
|
|
7
|
-
|
|
8
|
-
用法:
|
|
9
|
-
python draftgo_push.py pages [page_id ...]
|
|
10
|
-
python draftgo_push.py nav [nav_id ...]
|
|
11
|
-
python draftgo_push.py db_meta [db_meta_id ...]
|
|
12
|
-
python draftgo_push.py aihub [aihub_id ...]
|
|
13
|
-
python draftgo_push.py system_config [config_key ...]
|
|
14
|
-
python draftgo_push.py docs [article_id ...]
|
|
15
|
-
python draftgo_push.py doc_categories [category_id ...]
|
|
16
|
-
python draftgo_push.py custom_scripts [script_id ...]
|
|
17
|
-
python draftgo_push.py roles [role_id ...]
|
|
18
|
-
python draftgo_push.py users [user_id ...]
|
|
19
|
-
"""
|
|
20
|
-
import json, sys, re, datetime
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
import urllib.request, urllib.error
|
|
23
|
-
|
|
24
|
-
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
25
|
-
PROBE_ROUTES = False
|
|
26
|
-
DRY_RUN = False
|
|
27
|
-
RUN_FAILURES = 0
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def find_project_root(start: Path) -> Path:
|
|
31
|
-
"""向上查找包含 `.draftgo/` 的目录作为项目根。"""
|
|
32
|
-
cur = start
|
|
33
|
-
for _ in range(10):
|
|
34
|
-
if (cur / ".draftgo").is_dir():
|
|
35
|
-
return cur
|
|
36
|
-
if cur.parent == cur:
|
|
37
|
-
break
|
|
38
|
-
cur = cur.parent
|
|
39
|
-
# CWD 回退:脚本可能在中文路径下被调用,SCRIPT_DIR 解析失败时用 CWD
|
|
40
|
-
cwd = Path.cwd()
|
|
41
|
-
if (cwd / ".draftgo").is_dir():
|
|
42
|
-
return cwd
|
|
43
|
-
return start.parents[3] if len(start.parents) >= 4 else start
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
DEFAULT_ROOT = find_project_root(SCRIPT_DIR)
|
|
47
|
-
|
|
48
|
-
def load_config():
|
|
49
|
-
cfg_path = DEFAULT_ROOT / ".draftgo/config.json"
|
|
50
|
-
if not cfg_path.exists():
|
|
51
|
-
print("ERR: .draftgo/config.json not found, run /draftgo init first", file=sys.stderr)
|
|
52
|
-
sys.exit(1)
|
|
53
|
-
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
54
|
-
return cfg["server"].rstrip("/"), cfg["token"], cfg
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _lessons_reminder(cfg):
|
|
58
|
-
if DRY_RUN:
|
|
59
|
-
return
|
|
60
|
-
if not cfg.get("lessons_on_push", False):
|
|
61
|
-
return
|
|
62
|
-
print("")
|
|
63
|
-
print("[Lessons 回顾] Push 完成。回顾本次是否需要写入 .draftgo/lessons/:")
|
|
64
|
-
print(" - 踩了文档没写的坑")
|
|
65
|
-
print(" - 尝试了 ≥2 次才找到正确方案")
|
|
66
|
-
print(" - 发现了违反直觉的行为或约束")
|
|
67
|
-
print(" - 产出了通用性强的设计模式")
|
|
68
|
-
print(" 未命中则跳过。")
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def api_call(method, server, token, path, body=None, acceptable_errors=()):
|
|
72
|
-
"""发起 API 调用,并记录调用方不会恢复的失败。"""
|
|
73
|
-
global RUN_FAILURES
|
|
74
|
-
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
|
|
75
|
-
headers = {
|
|
76
|
-
"Authorization": f"Bearer {token}",
|
|
77
|
-
"User-Agent": (
|
|
78
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
79
|
-
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
|
80
|
-
),
|
|
81
|
-
"Accept": "application/json",
|
|
82
|
-
}
|
|
83
|
-
if data is not None:
|
|
84
|
-
headers["Content-Type"] = "application/json"
|
|
85
|
-
req = urllib.request.Request(
|
|
86
|
-
f"{server}{path}",
|
|
87
|
-
data=data,
|
|
88
|
-
method=method,
|
|
89
|
-
headers=headers,
|
|
90
|
-
)
|
|
91
|
-
try:
|
|
92
|
-
with urllib.request.urlopen(req, timeout=15) as r:
|
|
93
|
-
return True, r.status, r.read().decode("utf-8", errors="replace")
|
|
94
|
-
except urllib.error.HTTPError as e:
|
|
95
|
-
body_text = e.read().decode(errors="replace")
|
|
96
|
-
if e.code not in acceptable_errors:
|
|
97
|
-
RUN_FAILURES += 1
|
|
98
|
-
return False, e.code, body_text
|
|
99
|
-
except Exception as e:
|
|
100
|
-
RUN_FAILURES += 1
|
|
101
|
-
return False, None, str(e)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def _info(ok, status, body):
|
|
105
|
-
"""把 api_call 三元组压成简短描述,兼容旧打印格式。"""
|
|
106
|
-
if ok:
|
|
107
|
-
return status
|
|
108
|
-
if status is None:
|
|
109
|
-
return body or "error"
|
|
110
|
-
return f"HTTP {status}: {body}"
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def _extract_created_id(body_text):
|
|
114
|
-
"""从 POST 成功响应中提取新建资源的 id。
|
|
115
|
-
信封格式: {code, data: {id:...}} 或 {code, data: [{id:...}]}
|
|
116
|
-
"""
|
|
117
|
-
try:
|
|
118
|
-
resp = json.loads(body_text)
|
|
119
|
-
data = resp.get("data") if isinstance(resp, dict) else None
|
|
120
|
-
if isinstance(data, list):
|
|
121
|
-
data = data[0] if data else None
|
|
122
|
-
if isinstance(data, dict):
|
|
123
|
-
return data.get("id")
|
|
124
|
-
except Exception:
|
|
125
|
-
pass
|
|
126
|
-
return None
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def _writeback_index(rel_index, items):
|
|
130
|
-
"""将更新后的 index 列表写回 .draftgo/ 下的 index.json。"""
|
|
131
|
-
path = DEFAULT_ROOT / ".draftgo" / rel_index
|
|
132
|
-
if not path.exists() and not (DEFAULT_ROOT / ".draftgo").exists():
|
|
133
|
-
path = DEFAULT_ROOT / rel_index
|
|
134
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
-
path.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def _safe_slug(s):
|
|
139
|
-
"""与 pull 脚本一致的文件名 slug 规则。"""
|
|
140
|
-
return str(s).strip("/").replace("/", "_") or "root"
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def _rename_to_canonical(sub_dir, old_rel, prefix, new_id, slug_src, ext):
|
|
144
|
-
"""新建成功后把本地文件重命名为 pull 约定的 {prefix}_{id}_{slug}.{ext},
|
|
145
|
-
与 /draftgo pull 落地命名保持一致。返回新的相对路径(.draftgo/...);
|
|
146
|
-
任何异常都回退为原相对路径,不阻断推送。"""
|
|
147
|
-
new_name = f"{prefix}_{new_id}_{_safe_slug(slug_src)}.{ext}"
|
|
148
|
-
new_rel = f".draftgo/{sub_dir}/{new_name}"
|
|
149
|
-
try:
|
|
150
|
-
old_path = DEFAULT_ROOT / old_rel
|
|
151
|
-
new_path = DEFAULT_ROOT / new_rel
|
|
152
|
-
if old_path.exists() and old_path.resolve() != new_path.resolve():
|
|
153
|
-
old_path.rename(new_path)
|
|
154
|
-
return new_rel
|
|
155
|
-
except Exception:
|
|
156
|
-
return old_rel
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def _load_index(rel_index, allow_missing=False):
|
|
160
|
-
"""读取 .draftgo/ 下的 index.json;支持旧 (pages/index.json) 与新 (.draftgo/pages/index.json) 两种布局。"""
|
|
161
|
-
new_path = DEFAULT_ROOT / ".draftgo" / rel_index
|
|
162
|
-
legacy_path = DEFAULT_ROOT / rel_index
|
|
163
|
-
path = new_path if new_path.exists() else legacy_path
|
|
164
|
-
if not path.exists():
|
|
165
|
-
if allow_missing:
|
|
166
|
-
print(f" WARN {rel_index} 不存在,将尝试从云端恢复指定目标的索引")
|
|
167
|
-
return []
|
|
168
|
-
print(f"ERR: {rel_index} not found under .draftgo/, run /draftgo init first", file=sys.stderr)
|
|
169
|
-
sys.exit(1)
|
|
170
|
-
return json.loads(path.read_text(encoding="utf-8"))
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def _filter_items_by_ids(items, ids, id_key, type_name, orphan=None):
|
|
174
|
-
"""按用户指定 id 过滤;指定 id 不存在时直接失败,避免 0 目标假成功。"""
|
|
175
|
-
if not ids:
|
|
176
|
-
return items
|
|
177
|
-
wanted = [str(x) for x in ids]
|
|
178
|
-
found = {str(it.get(id_key)) for it in items if it.get(id_key) is not None}
|
|
179
|
-
missing = [x for x in wanted if x not in found]
|
|
180
|
-
if missing:
|
|
181
|
-
print(f"ERR: {type_name} index.json 未登记目标 {id_key}: {', '.join(missing)}", file=sys.stderr)
|
|
182
|
-
if orphan:
|
|
183
|
-
hints = orphan(missing)
|
|
184
|
-
if hints:
|
|
185
|
-
print(" 发现疑似未入索引的本地文件:", file=sys.stderr)
|
|
186
|
-
for hint in hints[:8]:
|
|
187
|
-
print(f" - {hint}", file=sys.stderr)
|
|
188
|
-
print(" 请先恢复/追加对应 index 元数据,或重新运行 pull 后再 push。", file=sys.stderr)
|
|
189
|
-
sys.exit(1)
|
|
190
|
-
return [it for it in items if str(it.get(id_key)) in wanted]
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
def _response_items(body):
|
|
194
|
-
"""解析列表接口的常见统一信封格式。"""
|
|
195
|
-
try:
|
|
196
|
-
raw = json.loads(body)
|
|
197
|
-
except Exception:
|
|
198
|
-
return []
|
|
199
|
-
if isinstance(raw, dict) and "data" in raw:
|
|
200
|
-
raw = raw["data"]
|
|
201
|
-
if isinstance(raw, list):
|
|
202
|
-
return raw
|
|
203
|
-
if isinstance(raw, dict):
|
|
204
|
-
for key in ("items", "results", "data"):
|
|
205
|
-
if isinstance(raw.get(key), list):
|
|
206
|
-
return raw[key]
|
|
207
|
-
return [raw]
|
|
208
|
-
return []
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
def _response_item(body):
|
|
212
|
-
"""解析单资源响应的统一信封。"""
|
|
213
|
-
try:
|
|
214
|
-
raw = json.loads(body)
|
|
215
|
-
except Exception:
|
|
216
|
-
return None
|
|
217
|
-
if isinstance(raw, dict) and "data" in raw:
|
|
218
|
-
raw = raw["data"]
|
|
219
|
-
return raw if isinstance(raw, dict) else None
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
def _cloud_version_allows_push(server, token, path, local_item, label):
|
|
223
|
-
"""云端优先:本地 pull 基线落后于云端时禁止静默覆盖。
|
|
224
|
-
|
|
225
|
-
没有历史 updated_at 的旧缓存保持兼容;404 由调用方按默认重建策略处理。
|
|
226
|
-
"""
|
|
227
|
-
global RUN_FAILURES
|
|
228
|
-
baseline = local_item.get("updated_at")
|
|
229
|
-
if not baseline:
|
|
230
|
-
return True
|
|
231
|
-
ok, status, body = api_call("GET", server, token, path, acceptable_errors=(404,))
|
|
232
|
-
if not ok and status == 404:
|
|
233
|
-
return True
|
|
234
|
-
if not ok:
|
|
235
|
-
return False
|
|
236
|
-
remote = _response_item(body)
|
|
237
|
-
remote_updated = remote.get("updated_at") if remote else None
|
|
238
|
-
if remote_updated and str(remote_updated) != str(baseline):
|
|
239
|
-
RUN_FAILURES += 1
|
|
240
|
-
conflict_dir = DEFAULT_ROOT / ".draftgo" / "sync-conflicts"
|
|
241
|
-
conflict_dir.mkdir(parents=True, exist_ok=True)
|
|
242
|
-
identity = str(local_item.get("id") or label).replace("/", "_").replace("\\", "_")
|
|
243
|
-
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
244
|
-
conflict_path = conflict_dir / f"{identity}-{stamp}.json"
|
|
245
|
-
conflict_path.write_text(json.dumps({
|
|
246
|
-
"kind": "remote-newer",
|
|
247
|
-
"resource_path": path,
|
|
248
|
-
"baseline_updated_at": baseline,
|
|
249
|
-
"remote_updated_at": remote_updated,
|
|
250
|
-
"local": local_item,
|
|
251
|
-
"remote": remote,
|
|
252
|
-
}, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
|
|
253
|
-
print(
|
|
254
|
-
f" ERR [{label}] 云端版本已变化(local={baseline}, remote={remote_updated});"
|
|
255
|
-
f"云端优先,已保存冲突快照:{conflict_path.relative_to(DEFAULT_ROOT)}。",
|
|
256
|
-
file=sys.stderr,
|
|
257
|
-
)
|
|
258
|
-
return False
|
|
259
|
-
return True
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
def _recover_missing_index_entries(
|
|
263
|
-
server, token, items, ids, id_key, type_name, rel_index, endpoint,
|
|
264
|
-
file_spec=None, drop_fields=(),
|
|
265
|
-
):
|
|
266
|
-
"""补齐缺失的 index 条目,且绝不覆盖本地受管内容。
|
|
267
|
-
|
|
268
|
-
只有显式指定的目标才可恢复,防止 `push --all` 把不明文件自动纳管。
|
|
269
|
-
file_spec 为 (目录, index 文件字段, 文件名前缀, 扩展名列表)。
|
|
270
|
-
"""
|
|
271
|
-
if not ids:
|
|
272
|
-
return items
|
|
273
|
-
wanted = [str(value) for value in ids]
|
|
274
|
-
registered = {str(item.get(id_key)) for item in items if item.get(id_key) is not None}
|
|
275
|
-
missing = [value for value in wanted if value not in registered]
|
|
276
|
-
if not missing:
|
|
277
|
-
return items
|
|
278
|
-
if DRY_RUN:
|
|
279
|
-
print(
|
|
280
|
-
f"ERR: {type_name} index.json 未登记目标 {id_key}: {', '.join(missing)}。"
|
|
281
|
-
"dry-run 不会回读云端或修改本地索引。",
|
|
282
|
-
file=sys.stderr,
|
|
283
|
-
)
|
|
284
|
-
sys.exit(1)
|
|
285
|
-
|
|
286
|
-
ok, status, body = api_call("GET", server, token, endpoint)
|
|
287
|
-
if not ok:
|
|
288
|
-
print(
|
|
289
|
-
f"ERR: 无法恢复 {type_name} index.json:云端回读失败 -> {_info(ok, status, body)}",
|
|
290
|
-
file=sys.stderr,
|
|
291
|
-
)
|
|
292
|
-
sys.exit(1)
|
|
293
|
-
remote_by_id = {
|
|
294
|
-
str(item.get(id_key)): item
|
|
295
|
-
for item in _response_items(body)
|
|
296
|
-
if isinstance(item, dict) and item.get(id_key) is not None
|
|
297
|
-
}
|
|
298
|
-
absent = [value for value in missing if value not in remote_by_id]
|
|
299
|
-
if absent:
|
|
300
|
-
print(
|
|
301
|
-
f"ERR: {type_name} 云端不存在目标 {id_key}: {', '.join(absent)}。"
|
|
302
|
-
"本地资源可能已成为孤儿文件,请确认后删除本地文件及其引用,"
|
|
303
|
-
"或改为新建资源推送。",
|
|
304
|
-
file=sys.stderr,
|
|
305
|
-
)
|
|
306
|
-
if file_spec:
|
|
307
|
-
rel_dir, _, prefix, exts = file_spec
|
|
308
|
-
hints = _orphan_hints(rel_dir, prefix, absent, exts)
|
|
309
|
-
for hint in hints:
|
|
310
|
-
print(f" 建议删除:{hint}", file=sys.stderr)
|
|
311
|
-
sys.exit(1)
|
|
312
|
-
|
|
313
|
-
recovered = []
|
|
314
|
-
for value in missing:
|
|
315
|
-
meta = dict(remote_by_id[value])
|
|
316
|
-
if file_spec:
|
|
317
|
-
rel_dir, file_field, prefix, exts = file_spec
|
|
318
|
-
candidates = _orphan_hints(rel_dir, prefix, [value], exts)
|
|
319
|
-
if len(candidates) != 1:
|
|
320
|
-
detail = "未找到" if not candidates else f"找到 {len(candidates)} 个"
|
|
321
|
-
print(
|
|
322
|
-
f"ERR: {type_name} {id_key}={value} 云端存在,但本地孤儿文件{detail},"
|
|
323
|
-
"无法安全补齐索引。请恢复唯一的本地文件或运行 pull 后重试。",
|
|
324
|
-
file=sys.stderr,
|
|
325
|
-
)
|
|
326
|
-
sys.exit(1)
|
|
327
|
-
meta[file_field] = candidates[0]
|
|
328
|
-
for field in drop_fields:
|
|
329
|
-
meta.pop(field, None)
|
|
330
|
-
items.append(meta)
|
|
331
|
-
recovered.append(value)
|
|
332
|
-
|
|
333
|
-
_writeback_index(rel_index, items)
|
|
334
|
-
print(f" OK {type_name}: 已从云端补齐 index.json 目标 {', '.join(recovered)},继续推送本地内容")
|
|
335
|
-
return items
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def _orphan_hints(rel_dir, prefix, missing_ids, exts):
|
|
339
|
-
base = DEFAULT_ROOT / ".draftgo" / rel_dir
|
|
340
|
-
if not base.exists():
|
|
341
|
-
return []
|
|
342
|
-
hints = []
|
|
343
|
-
for mid in missing_ids:
|
|
344
|
-
for ext in exts:
|
|
345
|
-
for p in base.glob(f"{prefix}_{mid}_*{ext}"):
|
|
346
|
-
hints.append(f".draftgo/{rel_dir}/{p.name}")
|
|
347
|
-
return hints
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
def _warn_orphan_files(rel_dir, items, file_field, prefix, exts, type_name):
|
|
351
|
-
base = DEFAULT_ROOT / ".draftgo" / rel_dir
|
|
352
|
-
if not base.exists():
|
|
353
|
-
return
|
|
354
|
-
indexed = {str(it.get(file_field, "")).replace("\\", "/") for it in items}
|
|
355
|
-
orphans = []
|
|
356
|
-
for p in base.iterdir():
|
|
357
|
-
if not p.is_file():
|
|
358
|
-
continue
|
|
359
|
-
if not p.name.startswith(f"{prefix}_") or p.suffix not in exts:
|
|
360
|
-
continue
|
|
361
|
-
rel = f".draftgo/{rel_dir}/{p.name}"
|
|
362
|
-
if rel not in indexed:
|
|
363
|
-
orphans.append(rel)
|
|
364
|
-
if orphans:
|
|
365
|
-
print(f" WARN {type_name}: 发现 {len(orphans)} 个未登记到 index.json 的本地文件,本次不会推送:")
|
|
366
|
-
for rel in orphans[:8]:
|
|
367
|
-
print(f" - {rel}")
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
def _summary(type_name, targets):
|
|
371
|
-
print(f" SUMMARY {type_name}: targets={targets}")
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
def _preview(type_name, items, id_key="id"):
|
|
375
|
-
"""打印不会产生云端或本地副作用的推送预览。"""
|
|
376
|
-
if not DRY_RUN:
|
|
377
|
-
return False
|
|
378
|
-
for item in items:
|
|
379
|
-
identity = item.get(id_key)
|
|
380
|
-
label = item.get("title") or item.get("name") or item.get("code") or identity or "(new)"
|
|
381
|
-
action = "UPDATE" if identity else "CREATE"
|
|
382
|
-
print(f" DRY-RUN {action} [{label}]")
|
|
383
|
-
_summary(type_name, len(items))
|
|
384
|
-
return True
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
def sync_pages(server, token, ids=None):
|
|
388
|
-
all_pages = _load_index("pages/index.json", allow_missing=bool(ids))
|
|
389
|
-
all_pages = _recover_missing_index_entries(
|
|
390
|
-
server, token, all_pages, ids, "id", "pages", "pages/index.json", "/api/pages/",
|
|
391
|
-
file_spec=("pages", "html_file", "page", [".html"]), drop_fields=("value",),
|
|
392
|
-
)
|
|
393
|
-
_warn_orphan_files("pages", all_pages, "html_file", "page", [".html"], "pages")
|
|
394
|
-
pages = _filter_items_by_ids(
|
|
395
|
-
all_pages, ids, "id", "pages",
|
|
396
|
-
orphan=lambda missing: _orphan_hints("pages", "page", missing, [".html"]),
|
|
397
|
-
)
|
|
398
|
-
if _preview("pages", pages):
|
|
399
|
-
return
|
|
400
|
-
dirty = False
|
|
401
|
-
for page in pages:
|
|
402
|
-
pid = page.get("id")
|
|
403
|
-
title = page.get("title", pid or "(new)")
|
|
404
|
-
html_file = DEFAULT_ROOT / page.get("html_file", "")
|
|
405
|
-
if not html_file.exists():
|
|
406
|
-
print(f" SKIP [{title}] html_file not found: {page.get('html_file')}")
|
|
407
|
-
continue
|
|
408
|
-
html = html_file.read_text(encoding="utf-8")
|
|
409
|
-
_lint_page_html(html, title)
|
|
410
|
-
payload = {
|
|
411
|
-
"title": page.get("title", ""),
|
|
412
|
-
"route": page.get("route", ""),
|
|
413
|
-
"tag": page.get("tag"),
|
|
414
|
-
"menu": page.get("menu"),
|
|
415
|
-
"status": page.get("status", "active"),
|
|
416
|
-
"permission": page.get("permission", {}),
|
|
417
|
-
"value": {"html": html},
|
|
418
|
-
}
|
|
419
|
-
if pid:
|
|
420
|
-
if not _cloud_version_allows_push(server, token, f"/api/pages/{pid}", page, title):
|
|
421
|
-
continue
|
|
422
|
-
ok, status, body = api_call("PUT", server, token, f"/api/pages/{pid}", payload, acceptable_errors=(404,))
|
|
423
|
-
# PUT 404:本地 id 与云端不一致(删了重建 / 跨环境),按 route 自动创建
|
|
424
|
-
if not ok and status == 404:
|
|
425
|
-
print(f" WARN [{title}] page_id={pid} 不存在,尝试创建")
|
|
426
|
-
pid = None
|
|
427
|
-
else:
|
|
428
|
-
print(f" {'OK' if ok else 'ERR'} [{title}] page_id={pid}{'' if ok else ' -> ' + _info(ok, status, body)}")
|
|
429
|
-
if not pid:
|
|
430
|
-
ok, status, body = api_call("POST", server, token, "/api/pages/", payload)
|
|
431
|
-
new_id = _extract_created_id(body) if ok else None
|
|
432
|
-
if ok and new_id:
|
|
433
|
-
page["id"] = new_id
|
|
434
|
-
new_rel = _rename_to_canonical(
|
|
435
|
-
"pages", page.get("html_file", ""), "page", new_id,
|
|
436
|
-
page.get("route", new_id), "html")
|
|
437
|
-
page["html_file"] = new_rel
|
|
438
|
-
dirty = True
|
|
439
|
-
print(f" OK [{title}] 已创建 page_id={new_id}(已回写 index)")
|
|
440
|
-
else:
|
|
441
|
-
print(f" ERR [{title}] 创建失败 -> {_info(ok, status, body)}")
|
|
442
|
-
if dirty:
|
|
443
|
-
_writeback_index("pages/index.json", all_pages)
|
|
444
|
-
_summary("pages", len(pages))
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
def sync_db_meta(server, token, ids=None):
|
|
448
|
-
all_metas = _load_index("db_meta/index.json", allow_missing=bool(ids))
|
|
449
|
-
all_metas = _recover_missing_index_entries(
|
|
450
|
-
server, token, all_metas, ids, "id", "db_meta", "db_meta/index.json", "/api/db-meta",
|
|
451
|
-
)
|
|
452
|
-
metas = _filter_items_by_ids(all_metas, ids, "id", "db_meta")
|
|
453
|
-
if _preview("db_meta", metas):
|
|
454
|
-
return
|
|
455
|
-
dirty = False
|
|
456
|
-
for meta in metas:
|
|
457
|
-
mid = meta.get("id")
|
|
458
|
-
label = meta.get("label", meta.get("type", mid or "(new)"))
|
|
459
|
-
payload = {
|
|
460
|
-
"type": meta.get("type"),
|
|
461
|
-
"label": meta.get("label"),
|
|
462
|
-
"describe": meta.get("describe"),
|
|
463
|
-
"schema": meta.get("schema"),
|
|
464
|
-
"permission": meta.get("permission"),
|
|
465
|
-
"schema_validation": meta.get("schema_validation", 0),
|
|
466
|
-
"extra": meta.get("extra"),
|
|
467
|
-
}
|
|
468
|
-
if mid:
|
|
469
|
-
ok, status, body = api_call("PUT", server, token, f"/api/db-meta/{mid}", payload, acceptable_errors=(404,))
|
|
470
|
-
# 本地 index 的 id 与云端不一致(删了重建 / 跨环境同步)时 PUT 404,按 type 创建
|
|
471
|
-
if not ok and status == 404:
|
|
472
|
-
print(f" WARN [{label}] db_meta_id={mid} 不存在,尝试按 type 创建")
|
|
473
|
-
mid = None
|
|
474
|
-
else:
|
|
475
|
-
print(f" {'OK' if ok else 'ERR'} [{label}] db_meta_id={mid}{'' if ok else ' -> ' + _info(ok, status, body)}")
|
|
476
|
-
if not mid:
|
|
477
|
-
ok, status, body = api_call("POST", server, token, "/api/db-meta", payload)
|
|
478
|
-
new_id = _extract_created_id(body) if ok else None
|
|
479
|
-
if ok:
|
|
480
|
-
if new_id:
|
|
481
|
-
meta["id"] = new_id
|
|
482
|
-
dirty = True
|
|
483
|
-
print(f" OK [{label}] 已创建 db_meta_id={new_id}{'(已回写 index)' if new_id else ''}")
|
|
484
|
-
else:
|
|
485
|
-
print(f" ERR [{label}] 创建失败 -> {_info(ok, status, body)}")
|
|
486
|
-
if dirty:
|
|
487
|
-
_writeback_index("db_meta/index.json", all_metas)
|
|
488
|
-
_summary("db_meta", len(metas))
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
def sync_nav(server, token, ids=None):
|
|
492
|
-
all_navs = _load_index("navigations/index.json", allow_missing=bool(ids))
|
|
493
|
-
all_navs = _recover_missing_index_entries(
|
|
494
|
-
server, token, all_navs, ids, "id", "nav", "navigations/index.json", "/api/navigations",
|
|
495
|
-
file_spec=("navigations", "html_file", "nav", [".html"]), drop_fields=("html",),
|
|
496
|
-
)
|
|
497
|
-
_warn_orphan_files("navigations", all_navs, "html_file", "nav", [".html"], "nav")
|
|
498
|
-
navs = _filter_items_by_ids(
|
|
499
|
-
all_navs, ids, "id", "nav",
|
|
500
|
-
orphan=lambda missing: _orphan_hints("navigations", "nav", missing, [".html"]),
|
|
501
|
-
)
|
|
502
|
-
if _preview("nav", navs):
|
|
503
|
-
return
|
|
504
|
-
dirty = False
|
|
505
|
-
for nav in navs:
|
|
506
|
-
nid = nav.get("id")
|
|
507
|
-
name = nav.get("name", nid or "(new)")
|
|
508
|
-
html_file = DEFAULT_ROOT / nav.get("html_file", "")
|
|
509
|
-
if not html_file.exists():
|
|
510
|
-
print(f" SKIP [{name}] html_file not found: {nav.get('html_file')}")
|
|
511
|
-
continue
|
|
512
|
-
html = html_file.read_text(encoding="utf-8")
|
|
513
|
-
if nid:
|
|
514
|
-
ok, status, body = api_call("PUT", server, token, f"/api/navigations/{nid}", {"html": html}, acceptable_errors=(404,))
|
|
515
|
-
if not ok and status == 404:
|
|
516
|
-
print(f" WARN [{name}] nav_id={nid} 不存在,尝试创建")
|
|
517
|
-
nid = None
|
|
518
|
-
else:
|
|
519
|
-
print(f" {'OK' if ok else 'ERR'} [{name}] nav_id={nid}{'' if ok else ' -> ' + _info(ok, status, body)}")
|
|
520
|
-
if not nid:
|
|
521
|
-
# 创建需要完整字段(name/code 必填),PUT 不发的字段在此补齐
|
|
522
|
-
payload = {
|
|
523
|
-
"name": nav.get("name", ""),
|
|
524
|
-
"code": nav.get("code", ""),
|
|
525
|
-
"html": html,
|
|
526
|
-
"tag": nav.get("tag"),
|
|
527
|
-
"order": nav.get("order", 0),
|
|
528
|
-
"status": nav.get("status", "active"),
|
|
529
|
-
}
|
|
530
|
-
ok, status, body = api_call("POST", server, token, "/api/navigations", payload)
|
|
531
|
-
new_id = _extract_created_id(body) if ok else None
|
|
532
|
-
if ok and new_id:
|
|
533
|
-
nav["id"] = new_id
|
|
534
|
-
new_rel = _rename_to_canonical(
|
|
535
|
-
"navigations", nav.get("html_file", ""), "nav", new_id,
|
|
536
|
-
nav.get("code") or nav.get("name") or new_id, "html")
|
|
537
|
-
nav["html_file"] = new_rel
|
|
538
|
-
dirty = True
|
|
539
|
-
print(f" OK [{name}] 已创建 nav_id={new_id}(已回写 index)")
|
|
540
|
-
else:
|
|
541
|
-
print(f" ERR [{name}] 创建失败 -> {_info(ok, status, body)}")
|
|
542
|
-
if dirty:
|
|
543
|
-
_writeback_index("navigations/index.json", all_navs)
|
|
544
|
-
_summary("nav", len(navs))
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
def sync_aihub(server, token, ids=None):
|
|
548
|
-
all_items = _load_index("aihub/index.json", allow_missing=bool(ids))
|
|
549
|
-
all_items = _recover_missing_index_entries(
|
|
550
|
-
server, token, all_items, ids, "id", "aihub", "aihub/index.json", "/api/aihub",
|
|
551
|
-
)
|
|
552
|
-
items = _filter_items_by_ids(all_items, ids, "id", "aihub")
|
|
553
|
-
if _preview("aihub", items):
|
|
554
|
-
return
|
|
555
|
-
dirty = False
|
|
556
|
-
for it in items:
|
|
557
|
-
iid = it.get("id")
|
|
558
|
-
name = it.get("name", iid or "(new)")
|
|
559
|
-
# 服务端 AIHubUpdate 接受的字段子集
|
|
560
|
-
payload = {k: it.get(k) for k in (
|
|
561
|
-
"type", "name", "data", "priority", "version",
|
|
562
|
-
"tags", "describe", "permission", "status",
|
|
563
|
-
) if it.get(k) is not None}
|
|
564
|
-
if iid:
|
|
565
|
-
ok, status, body = api_call("PUT", server, token, f"/api/aihub/{iid}", payload, acceptable_errors=(404,))
|
|
566
|
-
if not ok and status == 404:
|
|
567
|
-
print(f" WARN [{name}] aihub_id={iid} 不存在,尝试创建")
|
|
568
|
-
iid = None
|
|
569
|
-
else:
|
|
570
|
-
print(f" {'OK' if ok else 'ERR'} [{name}] aihub_id={iid}{'' if ok else ' -> ' + _info(ok, status, body)}")
|
|
571
|
-
if not iid:
|
|
572
|
-
create_payload = {k: payload.get(k) for k in (
|
|
573
|
-
"type", "name", "data", "priority", "version",
|
|
574
|
-
"tags", "describe", "permission",
|
|
575
|
-
) if payload.get(k) is not None}
|
|
576
|
-
ok, status, body = api_call("POST", server, token, "/api/aihub", create_payload)
|
|
577
|
-
new_id = _extract_created_id(body) if ok else None
|
|
578
|
-
if ok and new_id:
|
|
579
|
-
it["id"] = new_id
|
|
580
|
-
dirty = True
|
|
581
|
-
print(f" OK [{name}] 已创建 aihub_id={new_id}(已回写 index)")
|
|
582
|
-
else:
|
|
583
|
-
print(f" ERR [{name}] 创建失败 -> {_info(ok, status, body)}")
|
|
584
|
-
if dirty:
|
|
585
|
-
_writeback_index("aihub/index.json", all_items)
|
|
586
|
-
_summary("aihub", len(items))
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
def sync_system_config(server, token, keys=None):
|
|
590
|
-
items = _load_index("system_config/index.json", allow_missing=bool(keys))
|
|
591
|
-
items = _recover_missing_index_entries(
|
|
592
|
-
server, token, items, keys, "config_key", "system_config", "system_config/index.json", "/api/system/config",
|
|
593
|
-
)
|
|
594
|
-
items = _filter_items_by_ids(items, keys, "config_key", "system_config")
|
|
595
|
-
if _preview("system_config", items, "config_key"):
|
|
596
|
-
return
|
|
597
|
-
for it in items:
|
|
598
|
-
ck = it.get("config_key")
|
|
599
|
-
if not ck:
|
|
600
|
-
continue
|
|
601
|
-
# 优先使用 parsed_value(init 时 GET 返回的解析值),其次 config_value
|
|
602
|
-
value = it.get("parsed_value") if "parsed_value" in it else it.get("config_value")
|
|
603
|
-
value_payload = {"config_value": value}
|
|
604
|
-
meta_payload = {
|
|
605
|
-
"config_value": value,
|
|
606
|
-
"value_type": it.get("value_type"),
|
|
607
|
-
"category": it.get("category"),
|
|
608
|
-
"description": it.get("description"),
|
|
609
|
-
"is_sensitive": it.get("is_sensitive"),
|
|
610
|
-
"status": it.get("status"),
|
|
611
|
-
}
|
|
612
|
-
meta_payload = {k: v for k, v in meta_payload.items() if v is not None}
|
|
613
|
-
# 前端全局层属于系统默认配置,默认字段的描述/分类/状态由基座维护。
|
|
614
|
-
# 推送时只更新值,避免旧 index 中的元信息触发“系统默认字段不允许修改字段描述”。
|
|
615
|
-
payload = value_payload if _is_frontend_global_config(it) else meta_payload
|
|
616
|
-
ok, status, body = api_call("PUT", server, token, f"/api/system/{ck}", payload, acceptable_errors=(404,))
|
|
617
|
-
if not ok and _is_protected_system_config_error(body) and payload != value_payload:
|
|
618
|
-
print(f" WARN [{ck}] 系统默认字段元信息受保护,改为仅推送 config_value")
|
|
619
|
-
ok, status, body = api_call("PUT", server, token, f"/api/system/{ck}", value_payload, acceptable_errors=(404,))
|
|
620
|
-
if not ok and status == 404:
|
|
621
|
-
print(f" WARN [{ck}] system_config 不存在,尝试创建")
|
|
622
|
-
create_payload = _system_config_create_payload(ck, it, value)
|
|
623
|
-
ok, status, body = api_call("POST", server, token, "/api/system/", create_payload)
|
|
624
|
-
print(f" {'OK' if ok else 'ERR'} [{ck}] 已创建{'' if ok else ' -> ' + _info(ok, status, body)}")
|
|
625
|
-
else:
|
|
626
|
-
print(f" {'OK' if ok else 'ERR'} [{ck}]{'' if ok else ' -> ' + _info(ok, status, body)}")
|
|
627
|
-
_summary("system_config", len(items))
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
def _is_frontend_global_config(item):
|
|
631
|
-
return (
|
|
632
|
-
str(item.get("category") or "") == "frontend_global"
|
|
633
|
-
or str(item.get("config_key") or "").startswith("frontend_global_")
|
|
634
|
-
)
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
def _is_protected_system_config_error(body):
|
|
638
|
-
text = body if isinstance(body, str) else json.dumps(body, ensure_ascii=False)
|
|
639
|
-
return "系统默认字段不允许修改字段" in text
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
def _system_config_create_payload(config_key, item, value):
|
|
643
|
-
return {
|
|
644
|
-
"config_key": config_key,
|
|
645
|
-
"config_value": value,
|
|
646
|
-
"value_type": item.get("value_type") or _infer_system_config_value_type(value),
|
|
647
|
-
"category": item.get("category") or "custom",
|
|
648
|
-
"description": item.get("description") or "",
|
|
649
|
-
"is_sensitive": bool(item.get("is_sensitive", False)),
|
|
650
|
-
"status": item.get("status") or "active",
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
def _infer_system_config_value_type(value):
|
|
655
|
-
if isinstance(value, bool):
|
|
656
|
-
return "bool"
|
|
657
|
-
if isinstance(value, int) and not isinstance(value, bool):
|
|
658
|
-
return "int"
|
|
659
|
-
if isinstance(value, (dict, list)):
|
|
660
|
-
return "json"
|
|
661
|
-
return "string"
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
def sync_roles(server, token, ids=None):
|
|
665
|
-
items = _load_index("roles/index.json", allow_missing=bool(ids))
|
|
666
|
-
items = _recover_missing_index_entries(
|
|
667
|
-
server, token, items, ids, "id", "roles", "roles/index.json", "/api/roles",
|
|
668
|
-
)
|
|
669
|
-
items = _filter_items_by_ids(items, ids, "id", "roles")
|
|
670
|
-
if _preview("roles", items):
|
|
671
|
-
return
|
|
672
|
-
for it in items:
|
|
673
|
-
rid = it.get("id")
|
|
674
|
-
code = it.get("code", rid)
|
|
675
|
-
# RoleUpdateRequest 字段
|
|
676
|
-
payload = {k: it.get(k) for k in (
|
|
677
|
-
"name", "description", "status", "sort_order", "user_visible",
|
|
678
|
-
) if it.get(k) is not None}
|
|
679
|
-
ok, status, body = api_call("PUT", server, token, f"/api/roles/{rid}", payload)
|
|
680
|
-
print(f" {'OK' if ok else 'ERR'} [{code}] role_id={rid}{'' if ok else ' -> ' + _info(ok, status, body)}")
|
|
681
|
-
_summary("roles", len(items))
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
def sync_users(server, token, ids=None):
|
|
685
|
-
items = _load_index("users/index.json", allow_missing=bool(ids))
|
|
686
|
-
items = _recover_missing_index_entries(
|
|
687
|
-
server, token, items, ids, "id", "users", "users/index.json", "/api/users",
|
|
688
|
-
)
|
|
689
|
-
items = _filter_items_by_ids(items, ids, "id", "users")
|
|
690
|
-
if _preview("users", items):
|
|
691
|
-
return
|
|
692
|
-
for it in items:
|
|
693
|
-
uid = it.get("id")
|
|
694
|
-
uname = it.get("username", uid)
|
|
695
|
-
# UserUpdateRequest 字段子集(不下发 password / role_ids 之类敏感修改)
|
|
696
|
-
payload = {k: it.get(k) for k in (
|
|
697
|
-
"username", "email", "phone_number", "nickname",
|
|
698
|
-
"avatar", "status", "notes",
|
|
699
|
-
) if it.get(k) is not None}
|
|
700
|
-
ok, status, body = api_call("PUT", server, token, f"/api/users/{uid}", payload)
|
|
701
|
-
print(f" {'OK' if ok else 'ERR'} [{uname}] user_id={uid}{'' if ok else ' -> ' + _info(ok, status, body)}")
|
|
702
|
-
_summary("users", len(items))
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
def sync_docs(server, token, ids=None):
|
|
706
|
-
"""文档文章:从 .draftgo/docs/articles/index.json + 同目录 .md 文件回填正文。"""
|
|
707
|
-
all_items = _load_index("docs/articles/index.json", allow_missing=bool(ids))
|
|
708
|
-
all_items = _recover_missing_index_entries(
|
|
709
|
-
server, token, all_items, ids, "id", "docs", "docs/articles/index.json", "/api/docs/admin/articles",
|
|
710
|
-
file_spec=("docs/articles", "content_file", "article", [".html", ".md"]), drop_fields=("content",),
|
|
711
|
-
)
|
|
712
|
-
_warn_orphan_files("docs/articles", all_items, "content_file", "article", [".html", ".md"], "docs")
|
|
713
|
-
items = _filter_items_by_ids(
|
|
714
|
-
all_items, ids, "id", "docs",
|
|
715
|
-
orphan=lambda missing: _orphan_hints("docs/articles", "article", missing, [".html", ".md"]),
|
|
716
|
-
)
|
|
717
|
-
if _preview("docs", items):
|
|
718
|
-
return
|
|
719
|
-
dirty = False
|
|
720
|
-
for it in items:
|
|
721
|
-
aid = it.get("id")
|
|
722
|
-
title = it.get("title", aid or "(new)")
|
|
723
|
-
content_rel = it.get("content_file") or ""
|
|
724
|
-
content_path = DEFAULT_ROOT / content_rel if content_rel else None
|
|
725
|
-
if not content_path or not content_path.exists():
|
|
726
|
-
print(f" SKIP [{title}] content_file not found: {content_rel}")
|
|
727
|
-
continue
|
|
728
|
-
content = content_path.read_text(encoding="utf-8")
|
|
729
|
-
# ArticleUpdate / ArticleCreate 接受字段(仅 title 必填,其余有默认值)
|
|
730
|
-
payload = {k: it.get(k) for k in (
|
|
731
|
-
"title", "slug", "category_id", "summary", "cover", "tags",
|
|
732
|
-
"status", "is_top", "sort_order", "seo_title", "seo_description",
|
|
733
|
-
"permission",
|
|
734
|
-
) if it.get(k) is not None}
|
|
735
|
-
payload["content"] = content
|
|
736
|
-
payload["content_type"] = "html"
|
|
737
|
-
if aid:
|
|
738
|
-
ok, status, body = api_call("PUT", server, token, f"/api/docs/articles/{aid}", payload, acceptable_errors=(404,))
|
|
739
|
-
if not ok and status == 404:
|
|
740
|
-
print(f" WARN [{title}] article_id={aid} 不存在,尝试创建")
|
|
741
|
-
aid = None
|
|
742
|
-
else:
|
|
743
|
-
print(f" {'OK' if ok else 'ERR'} [{title}] article_id={aid}{'' if ok else ' -> ' + _info(ok, status, body)}")
|
|
744
|
-
if not aid:
|
|
745
|
-
ok, status, body = api_call("POST", server, token, "/api/docs/articles", payload)
|
|
746
|
-
new_id = _extract_created_id(body) if ok else None
|
|
747
|
-
if ok and new_id:
|
|
748
|
-
it["id"] = new_id
|
|
749
|
-
new_rel = _rename_to_canonical(
|
|
750
|
-
"docs/articles", it.get("content_file", ""), "article", new_id,
|
|
751
|
-
it.get("slug") or it.get("title") or new_id, "html")
|
|
752
|
-
it["content_file"] = new_rel
|
|
753
|
-
dirty = True
|
|
754
|
-
print(f" OK [{title}] 已创建 article_id={new_id}(已回写 index)")
|
|
755
|
-
else:
|
|
756
|
-
print(f" ERR [{title}] 创建失败 -> {_info(ok, status, body)}")
|
|
757
|
-
if dirty:
|
|
758
|
-
_writeback_index("docs/articles/index.json", all_items)
|
|
759
|
-
_summary("docs", len(items))
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
def sync_doc_categories(server, token, ids=None):
|
|
763
|
-
all_items = _load_index("doc_categories/index.json", allow_missing=bool(ids))
|
|
764
|
-
all_items = _recover_missing_index_entries(
|
|
765
|
-
server, token, all_items, ids, "id", "doc_categories", "doc_categories/index.json", "/api/docs/categories?flat=true",
|
|
766
|
-
)
|
|
767
|
-
items = _filter_items_by_ids(all_items, ids, "id", "doc_categories")
|
|
768
|
-
if _preview("doc_categories", items):
|
|
769
|
-
return
|
|
770
|
-
dirty = False
|
|
771
|
-
for it in items:
|
|
772
|
-
cid = it.get("id")
|
|
773
|
-
name = it.get("name", cid or "(new)")
|
|
774
|
-
# CategoryUpdate 字段
|
|
775
|
-
payload = {k: it.get(k) for k in (
|
|
776
|
-
"name", "slug", "description", "icon", "parent_id", "sort_order", "status",
|
|
777
|
-
) if it.get(k) is not None}
|
|
778
|
-
if cid:
|
|
779
|
-
ok, status, body = api_call("PUT", server, token, f"/api/docs/categories/{cid}", payload, acceptable_errors=(404,))
|
|
780
|
-
if not ok and status == 404:
|
|
781
|
-
print(f" WARN [{name}] category_id={cid} 不存在,尝试创建")
|
|
782
|
-
cid = None
|
|
783
|
-
else:
|
|
784
|
-
print(f" {'OK' if ok else 'ERR'} [{name}] category_id={cid}{'' if ok else ' -> ' + _info(ok, status, body)}")
|
|
785
|
-
if not cid:
|
|
786
|
-
ok, status, body = api_call("POST", server, token, "/api/docs/categories", payload)
|
|
787
|
-
new_id = _extract_created_id(body) if ok else None
|
|
788
|
-
if ok and new_id:
|
|
789
|
-
it["id"] = new_id
|
|
790
|
-
dirty = True
|
|
791
|
-
print(f" OK [{name}] 已创建 category_id={new_id}(已回写 index)")
|
|
792
|
-
else:
|
|
793
|
-
print(f" ERR [{name}] 创建失败 -> {_info(ok, status, body)}")
|
|
794
|
-
if dirty:
|
|
795
|
-
_writeback_index("doc_categories/index.json", all_items)
|
|
796
|
-
_summary("doc_categories", len(items))
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
def _route_specs_from_code(code):
|
|
800
|
-
specs = []
|
|
801
|
-
for m in re.finditer(r'@route\(\s*["\']([A-Za-z]+)\s+([^"\']+)["\']\s*\)', code):
|
|
802
|
-
specs.append((m.group(1).upper(), m.group(2)))
|
|
803
|
-
for m in re.finditer(r'\.Route\(\s*["\']([A-Za-z]+)["\']\s*,\s*["\']([^"\']+)["\']', code):
|
|
804
|
-
specs.append((m.group(1).upper(), m.group(2)))
|
|
805
|
-
return specs
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
def _probe_script_routes(server, token, script_meta, code):
|
|
809
|
-
slug = script_meta.get("slug")
|
|
810
|
-
if not slug:
|
|
811
|
-
return
|
|
812
|
-
for method, route_path in _route_specs_from_code(code):
|
|
813
|
-
if method != "GET":
|
|
814
|
-
print(f" SKIP probe [{slug}] {method} {route_path}(只自动探测 GET,避免副作用)")
|
|
815
|
-
continue
|
|
816
|
-
path = route_path if route_path.startswith("/") else f"/{route_path}"
|
|
817
|
-
ok, status, body = api_call("GET", server, token, f"/api/x/{slug}{path}")
|
|
818
|
-
print(f" {'OK' if ok else 'ERR'} probe GET /api/x/{slug}{path}{'' if ok else ' -> ' + _info(ok, status, body)}")
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
def sync_custom_scripts(server, token, ids=None):
|
|
822
|
-
"""自定义脚本:从 .draftgo/custom_scripts/index.json + 同目录代码文件回填 code。"""
|
|
823
|
-
all_items = _load_index("custom_scripts/index.json", allow_missing=bool(ids))
|
|
824
|
-
all_items = _recover_missing_index_entries(
|
|
825
|
-
server, token, all_items, ids, "id", "custom_scripts", "custom_scripts/index.json", "/api/scripts/",
|
|
826
|
-
file_spec=("custom_scripts", "code_file", "script", [".py", ".js", ".ts", ".sh", ".go", ".txt"]), drop_fields=("code",),
|
|
827
|
-
)
|
|
828
|
-
_warn_orphan_files("custom_scripts", all_items, "code_file", "script", [".go"], "custom_scripts")
|
|
829
|
-
items = _filter_items_by_ids(
|
|
830
|
-
all_items, ids, "id", "custom_scripts",
|
|
831
|
-
orphan=lambda missing: _orphan_hints("custom_scripts", "script", missing, [".go"]),
|
|
832
|
-
)
|
|
833
|
-
if _preview("custom_scripts", items):
|
|
834
|
-
return
|
|
835
|
-
dirty = False
|
|
836
|
-
for it in items:
|
|
837
|
-
sid = it.get("id")
|
|
838
|
-
name = it.get("name") or it.get("slug") or sid or "(new)"
|
|
839
|
-
code_rel = it.get("code_file") or ""
|
|
840
|
-
code_path = DEFAULT_ROOT / code_rel if code_rel else None
|
|
841
|
-
if not code_path or not code_path.exists():
|
|
842
|
-
print(f" SKIP [{name}] code_file not found: {code_rel}")
|
|
843
|
-
continue
|
|
844
|
-
code = code_path.read_text(encoding="utf-8")
|
|
845
|
-
if sid:
|
|
846
|
-
if not _cloud_version_allows_push(server, token, f"/api/scripts/{sid}", it, name):
|
|
847
|
-
continue
|
|
848
|
-
# ScriptUpdate 接受字段(不含 slug/mode,避免误改启停/路由)
|
|
849
|
-
payload = {k: it.get(k) for k in (
|
|
850
|
-
"name", "description", "config", "permission", "go_mod", "go_sum",
|
|
851
|
-
) if it.get(k) is not None}
|
|
852
|
-
payload["code"] = code
|
|
853
|
-
ok, status, body = api_call("PUT", server, token, f"/api/scripts/{sid}", payload, acceptable_errors=(404,))
|
|
854
|
-
if not ok and status == 404:
|
|
855
|
-
print(f" WARN [{name}] script_id={sid} 不存在,尝试创建")
|
|
856
|
-
sid = None
|
|
857
|
-
else:
|
|
858
|
-
print(f" {'OK' if ok else 'ERR'} [{name}] script_id={sid}{'' if ok else ' -> ' + _info(ok, status, body)}")
|
|
859
|
-
if ok and PROBE_ROUTES and str(it.get("mode") or "").lower() in ("route", "mixed"):
|
|
860
|
-
_probe_script_routes(server, token, it, code)
|
|
861
|
-
if not sid:
|
|
862
|
-
# ScriptCreate 必填 name/slug/code/mode;触发来源统一以代码装饰器为准
|
|
863
|
-
payload = {
|
|
864
|
-
"name": it.get("name", ""),
|
|
865
|
-
"slug": it.get("slug", ""),
|
|
866
|
-
"code": code,
|
|
867
|
-
"mode": it.get("mode", "route"),
|
|
868
|
-
"config": it.get("config"),
|
|
869
|
-
"permission": it.get("permission"),
|
|
870
|
-
"description": it.get("description"),
|
|
871
|
-
"go_mod": it.get("go_mod"),
|
|
872
|
-
"go_sum": it.get("go_sum"),
|
|
873
|
-
}
|
|
874
|
-
payload = {k: v for k, v in payload.items() if v is not None}
|
|
875
|
-
ok, status, body = api_call("POST", server, token, "/api/scripts/", payload)
|
|
876
|
-
new_id = _extract_created_id(body) if ok else None
|
|
877
|
-
if ok and new_id:
|
|
878
|
-
it["id"] = new_id
|
|
879
|
-
ext = (code_rel.rsplit(".", 1)[-1] if "." in code_rel else "txt")
|
|
880
|
-
new_rel = _rename_to_canonical(
|
|
881
|
-
"custom_scripts", code_rel, "script", new_id,
|
|
882
|
-
it.get("slug") or it.get("name") or new_id, ext)
|
|
883
|
-
it["code_file"] = new_rel
|
|
884
|
-
dirty = True
|
|
885
|
-
print(f" OK [{name}] 已创建 script_id={new_id}(已回写 index)")
|
|
886
|
-
if PROBE_ROUTES and str(it.get("mode") or "").lower() in ("route", "mixed"):
|
|
887
|
-
_probe_script_routes(server, token, it, code)
|
|
888
|
-
else:
|
|
889
|
-
print(f" ERR [{name}] 创建失败 -> {_info(ok, status, body)}")
|
|
890
|
-
if dirty:
|
|
891
|
-
_writeback_index("custom_scripts/index.json", all_items)
|
|
892
|
-
_summary("custom_scripts", len(items))
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
def _lint_page_html(html, label):
|
|
896
|
-
"""Non-blocking lint for page HTML. Prints warnings, never raises."""
|
|
897
|
-
warnings = []
|
|
898
|
-
# Extract all <script> content
|
|
899
|
-
script_blocks = re.findall(r'<script[^>]*>(.*?)</script>', html, re.DOTALL | re.IGNORECASE)
|
|
900
|
-
for block_idx, block in enumerate(script_blocks):
|
|
901
|
-
# Detect let/const duplicate declarations in same scope
|
|
902
|
-
decls = {}
|
|
903
|
-
for line_offset, line in enumerate(block.split('\n'), start=1):
|
|
904
|
-
for m in re.finditer(r'\b(let|const)\s+([A-Za-z_$][\w$]*)', line):
|
|
905
|
-
kind, name = m.group(1), m.group(2)
|
|
906
|
-
if name in decls:
|
|
907
|
-
prev_kind, prev_line = decls[name]
|
|
908
|
-
warnings.append(
|
|
909
|
-
f"变量 '{name}' 重复声明 ({prev_kind} @行~{prev_line}, {kind} @行~{line_offset})"
|
|
910
|
-
)
|
|
911
|
-
else:
|
|
912
|
-
decls[name] = (kind, line_offset)
|
|
913
|
-
# Detect destructured let/const: let {a, b} = ... or let [a, b] = ...
|
|
914
|
-
for line_offset, line in enumerate(block.split('\n'), start=1):
|
|
915
|
-
for m in re.finditer(r'\b(let|const)\s*[\[{]([^}\]]+)[}\]]', line):
|
|
916
|
-
kind = m.group(1)
|
|
917
|
-
names_str = m.group(2)
|
|
918
|
-
for nm in re.findall(r'([A-Za-z_$][\w$]*)', names_str):
|
|
919
|
-
if nm in decls and decls[nm][1] != line_offset:
|
|
920
|
-
prev_kind, prev_line = decls[nm]
|
|
921
|
-
warnings.append(
|
|
922
|
-
f"变量 '{nm}' 重复声明 ({prev_kind} @行~{prev_line}, {kind} 解构 @行~{line_offset})"
|
|
923
|
-
)
|
|
924
|
-
# Detect browser native dialogs (alert/confirm/prompt)
|
|
925
|
-
for line_offset, line in enumerate(block.split('\n'), start=1):
|
|
926
|
-
# Match bare alert/confirm/prompt calls, exclude App.confirm
|
|
927
|
-
for m in re.finditer(r'(?<!\w)(?<!App\.)(alert|confirm|prompt)\s*\(', line):
|
|
928
|
-
fn = m.group(1)
|
|
929
|
-
alt = {'alert': 'App.showModal()', 'confirm': 'await App.confirm()', 'prompt': 'custom input modal'}
|
|
930
|
-
warnings.append(
|
|
931
|
-
f"禁止使用浏览器原生 {fn}() (@行~{line_offset}), 请用 {alt[fn]}"
|
|
932
|
-
)
|
|
933
|
-
if warnings:
|
|
934
|
-
print(f" [!] [lint] [{label}] found {len(warnings)} potential issue(s):")
|
|
935
|
-
for w in warnings:
|
|
936
|
-
print(f" {w}")
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
HANDLERS = {
|
|
940
|
-
"pages": sync_pages,
|
|
941
|
-
"nav": sync_nav,
|
|
942
|
-
"db_meta": sync_db_meta,
|
|
943
|
-
"aihub": sync_aihub,
|
|
944
|
-
"system_config": sync_system_config,
|
|
945
|
-
"roles": sync_roles,
|
|
946
|
-
"users": sync_users,
|
|
947
|
-
"docs": sync_docs,
|
|
948
|
-
"doc_categories": sync_doc_categories,
|
|
949
|
-
"custom_scripts": sync_custom_scripts,
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
def run_batch(server, token, args):
|
|
954
|
-
"""批量推送:--batch pages 1,2,3 nav 4 custom_scripts 7,8
|
|
955
|
-
解析为多组 (mode, [ids]) 依次执行,共享同一连接配置。"""
|
|
956
|
-
groups = []
|
|
957
|
-
i = 0
|
|
958
|
-
while i < len(args):
|
|
959
|
-
mode = args[i]
|
|
960
|
-
if mode not in HANDLERS:
|
|
961
|
-
print(f"ERR: unknown mode '{mode}' in --batch", file=sys.stderr)
|
|
962
|
-
sys.exit(1)
|
|
963
|
-
i += 1
|
|
964
|
-
ids = []
|
|
965
|
-
if i < len(args) and args[i] not in HANDLERS and not args[i].startswith("-"):
|
|
966
|
-
ids = args[i].split(",")
|
|
967
|
-
i += 1
|
|
968
|
-
groups.append((mode, ids or None))
|
|
969
|
-
print(f"[batch] {len(groups)} group(s) to push")
|
|
970
|
-
for mode, ids in groups:
|
|
971
|
-
print(f"\n--- {mode} {'(all)' if not ids else ','.join(ids)} ---")
|
|
972
|
-
HANDLERS[mode](server, token, ids)
|
|
973
|
-
result = "previewed" if DRY_RUN else "pushed"
|
|
974
|
-
print(f"\n[batch] done, {len(groups)} group(s) {result}")
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
def main():
|
|
978
|
-
global PROBE_ROUTES, DRY_RUN
|
|
979
|
-
if len(sys.argv) < 2:
|
|
980
|
-
print(f"usage: draftgo_push.py {{{'|'.join(HANDLERS)}}} [id ...]\n"
|
|
981
|
-
f" draftgo_push.py --batch <mode> <ids> [<mode> <ids> ...]")
|
|
982
|
-
sys.exit(1)
|
|
983
|
-
|
|
984
|
-
args = sys.argv[1:]
|
|
985
|
-
if "--probe-routes" in args:
|
|
986
|
-
PROBE_ROUTES = True
|
|
987
|
-
args = [a for a in args if a != "--probe-routes"]
|
|
988
|
-
if "--dry-run" in args:
|
|
989
|
-
DRY_RUN = True
|
|
990
|
-
args = [a for a in args if a != "--dry-run"]
|
|
991
|
-
|
|
992
|
-
if not args:
|
|
993
|
-
print(f"usage: draftgo_push.py {{{'|'.join(HANDLERS)}}} [id ...]")
|
|
994
|
-
sys.exit(1)
|
|
995
|
-
|
|
996
|
-
if args[0] == "--all":
|
|
997
|
-
args = ["--batch", *HANDLERS]
|
|
998
|
-
|
|
999
|
-
if args[0] == "--batch":
|
|
1000
|
-
server, token, cfg = load_config()
|
|
1001
|
-
run_batch(server, token, args[1:])
|
|
1002
|
-
_lessons_reminder(cfg)
|
|
1003
|
-
if RUN_FAILURES:
|
|
1004
|
-
print(f"ERR: push finished with {RUN_FAILURES} failed request(s)", file=sys.stderr)
|
|
1005
|
-
sys.exit(1)
|
|
1006
|
-
return
|
|
1007
|
-
|
|
1008
|
-
if args[0] not in HANDLERS:
|
|
1009
|
-
print(f"usage: draftgo_push.py {{{'|'.join(HANDLERS)}}} [id ...]")
|
|
1010
|
-
sys.exit(1)
|
|
1011
|
-
mode = args[0]
|
|
1012
|
-
ids = args[1:] or None
|
|
1013
|
-
server, token, cfg = load_config()
|
|
1014
|
-
HANDLERS[mode](server, token, ids)
|
|
1015
|
-
_lessons_reminder(cfg)
|
|
1016
|
-
if RUN_FAILURES:
|
|
1017
|
-
print(f"ERR: push finished with {RUN_FAILURES} failed request(s)", file=sys.stderr)
|
|
1018
|
-
sys.exit(1)
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
if __name__ == "__main__":
|
|
1022
|
-
main()
|