draftgo-cli 3.0.1 → 3.0.29

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.
Files changed (53) hide show
  1. package/README.md +67 -17
  2. package/package.json +13 -8
  3. package/resources/skill/SKILL.md +118 -22
  4. package/resources/skill/core/architecture.md +4 -24
  5. package/resources/skill/core/modules.md +14 -4
  6. package/resources/skill/init/SKILL.md +3 -4
  7. package/resources/skill/practices/anti-patterns.md +14 -4
  8. package/resources/skill/practices/best-practices.md +25 -6
  9. package/resources/skill/practices/dev-declaration.md +23 -3
  10. package/resources/skill/pull/SKILL.md +9 -1
  11. package/resources/skill/push/SKILL.md +103 -68
  12. package/resources/skill/quickref/api-endpoints.md +63 -41
  13. package/resources/skill/quickref/api.json +5084 -4975
  14. package/resources/skill/quickref/app-api.md +4 -14
  15. package/resources/skill/rules/dev-workflow.md +154 -57
  16. package/resources/skill/rules/frontend.md +569 -21
  17. package/resources/skill/rules/parallel.md +10 -10
  18. package/resources/skill/scripts/__pycache__/draftgo_pull.cpython-312.pyc +0 -0
  19. package/resources/skill/scripts/__pycache__/draftgo_push.cpython-312.pyc +0 -0
  20. package/resources/skill/scripts/draftgo_delete.py +0 -2
  21. package/resources/skill/scripts/draftgo_init.py +15 -3
  22. package/resources/skill/scripts/draftgo_pull.py +154 -87
  23. package/resources/skill/scripts/draftgo_push.py +363 -174
  24. package/resources/skill/specs/custom-services.md +199 -0
  25. package/resources/skill/specs/data.md +195 -5
  26. package/resources/skill/specs/db-relations.md +227 -0
  27. package/resources/skill/specs/runtime.md +30 -0
  28. package/resources/skill/specs/security.md +3 -3
  29. package/resources/skill/specs/ui-protocol.md +79 -48
  30. package/resources/skill/story/SKILL.md +2 -7
  31. package/src/cli.js +9 -0
  32. package/src/commands/api.js +59 -0
  33. package/src/commands/autoPush.js +41 -0
  34. package/src/commands/check.js +27 -17
  35. package/src/commands/delete.js +6 -4
  36. package/src/commands/deploy.js +31 -0
  37. package/src/commands/doctor.js +1 -1
  38. package/src/commands/help.js +27 -9
  39. package/src/commands/init.js +17 -2
  40. package/src/commands/map.js +18 -7
  41. package/src/commands/new.js +20 -17
  42. package/src/commands/sync.js +10 -3
  43. package/src/commands/update.js +15 -56
  44. package/src/commands/upgrade.js +52 -0
  45. package/src/commands/verifyUi.js +199 -0
  46. package/src/index.js +12 -1
  47. package/src/localdev/compose.js +8 -1
  48. package/src/platforms.js +3 -3
  49. package/src/projectConfig.js +11 -1
  50. package/src/projectMap.js +274 -39
  51. package/src/skill.js +113 -29
  52. package/src/updateCheck.js +37 -5
  53. package/resources/skill/quickref/dg-components.md +0 -198
@@ -2,7 +2,7 @@
2
2
  """
3
3
  DraftGo Push Script
4
4
  推送本地修改到云端。覆盖所有 init 拉取的类型:
5
- pages / nav / db_meta / aihub / external_apis / system_config / roles / users
5
+ pages / nav / db_meta / aihub / system_config / roles / users
6
6
  docs / doc_categories / custom_scripts
7
7
 
8
8
  用法:
@@ -10,19 +10,20 @@ DraftGo Push Script
10
10
  python draftgo_push.py nav [nav_id ...]
11
11
  python draftgo_push.py db_meta [db_meta_id ...]
12
12
  python draftgo_push.py aihub [aihub_id ...]
13
- python draftgo_push.py external_apis [api_id ...]
14
13
  python draftgo_push.py system_config [config_key ...]
15
14
  python draftgo_push.py docs [article_id ...]
16
15
  python draftgo_push.py doc_categories [category_id ...]
17
16
  python draftgo_push.py custom_scripts [script_id ...]
18
- python draftgo_push.py roles [role_id ...] ⚠️ 涉及权限,调用前应人工确认
19
- python draftgo_push.py users [user_id ...] ⚠️ 涉及账号,调用前应人工确认
17
+ python draftgo_push.py roles [role_id ...]
18
+ python draftgo_push.py users [user_id ...]
20
19
  """
21
20
  import json, sys, re
22
21
  from pathlib import Path
23
22
  import urllib.request, urllib.error
24
23
 
25
24
  SCRIPT_DIR = Path(__file__).resolve().parent
25
+ PROBE_ROUTES = False
26
+ DRY_RUN = False
26
27
 
27
28
 
28
29
  def find_project_root(start: Path) -> Path:
@@ -43,46 +44,6 @@ def find_project_root(start: Path) -> Path:
43
44
 
44
45
  DEFAULT_ROOT = find_project_root(SCRIPT_DIR)
45
46
 
46
- # ── 冲突检测 ──
47
- FORCE = False
48
-
49
-
50
- def fetch_get(server, token, path):
51
- """GET 请求,返回解析后的 JSON 或 None。"""
52
- ok, _status, body = api_call("GET", server, token, path)
53
- if not ok:
54
- return None
55
- try:
56
- return json.loads(body)
57
- except Exception:
58
- return None
59
-
60
-
61
- def check_conflict(server, token, path, local_updated_at, label):
62
- """检查云端 updated_at 是否与本地基线一致。返回 True 表示可以继续推送。"""
63
- if FORCE:
64
- return True
65
- if not local_updated_at:
66
- return True
67
- raw = fetch_get(server, token, path)
68
- if raw is None:
69
- print(f" WARN [{label}] 无法获取云端状态,跳过冲突检测")
70
- return True
71
- data = raw.get("data") if isinstance(raw, dict) else None
72
- if not isinstance(data, dict):
73
- return True
74
- remote_updated = data.get("updated_at")
75
- if not remote_updated:
76
- return True
77
- if str(remote_updated) != str(local_updated_at):
78
- print(f" SKIP [{label}] 云端已被他人修改")
79
- print(f" 本地基线: {local_updated_at}")
80
- print(f" 云端时间: {remote_updated}")
81
- print(f" 请先 pull 同步后再 push,或使用 --force 强制覆盖")
82
- return False
83
- return True
84
-
85
-
86
47
  def load_config():
87
48
  cfg_path = DEFAULT_ROOT / ".draftgo/config.json"
88
49
  if not cfg_path.exists():
@@ -93,6 +54,8 @@ def load_config():
93
54
 
94
55
 
95
56
  def _lessons_reminder(cfg):
57
+ if DRY_RUN:
58
+ return
96
59
  if not cfg.get("lessons_on_push", False):
97
60
  return
98
61
  print("")
@@ -159,8 +122,9 @@ def _extract_created_id(body_text):
159
122
  def _writeback_index(rel_index, items):
160
123
  """将更新后的 index 列表写回 .draftgo/ 下的 index.json。"""
161
124
  path = DEFAULT_ROOT / ".draftgo" / rel_index
162
- if not path.exists():
125
+ if not path.exists() and not (DEFAULT_ROOT / ".draftgo").exists():
163
126
  path = DEFAULT_ROOT / rel_index
127
+ path.parent.mkdir(parents=True, exist_ok=True)
164
128
  path.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8")
165
129
 
166
130
 
@@ -185,22 +149,196 @@ def _rename_to_canonical(sub_dir, old_rel, prefix, new_id, slug_src, ext):
185
149
  return old_rel
186
150
 
187
151
 
188
- def _load_index(rel_index):
152
+ def _load_index(rel_index, allow_missing=False):
189
153
  """读取 .draftgo/ 下的 index.json;支持旧 (pages/index.json) 与新 (.draftgo/pages/index.json) 两种布局。"""
190
154
  new_path = DEFAULT_ROOT / ".draftgo" / rel_index
191
155
  legacy_path = DEFAULT_ROOT / rel_index
192
156
  path = new_path if new_path.exists() else legacy_path
193
157
  if not path.exists():
158
+ if allow_missing:
159
+ print(f" WARN {rel_index} 不存在,将尝试从云端恢复指定目标的索引")
160
+ return []
194
161
  print(f"ERR: {rel_index} not found under .draftgo/, run /draftgo init first", file=sys.stderr)
195
162
  sys.exit(1)
196
163
  return json.loads(path.read_text(encoding="utf-8"))
197
164
 
198
165
 
166
+ def _filter_items_by_ids(items, ids, id_key, type_name, orphan=None):
167
+ """按用户指定 id 过滤;指定 id 不存在时直接失败,避免 0 目标假成功。"""
168
+ if not ids:
169
+ return items
170
+ wanted = [str(x) for x in ids]
171
+ found = {str(it.get(id_key)) for it in items if it.get(id_key) is not None}
172
+ missing = [x for x in wanted if x not in found]
173
+ if missing:
174
+ print(f"ERR: {type_name} index.json 未登记目标 {id_key}: {', '.join(missing)}", file=sys.stderr)
175
+ if orphan:
176
+ hints = orphan(missing)
177
+ if hints:
178
+ print(" 发现疑似未入索引的本地文件:", file=sys.stderr)
179
+ for hint in hints[:8]:
180
+ print(f" - {hint}", file=sys.stderr)
181
+ print(" 请先恢复/追加对应 index 元数据,或重新运行 pull 后再 push。", file=sys.stderr)
182
+ sys.exit(1)
183
+ return [it for it in items if str(it.get(id_key)) in wanted]
184
+
185
+
186
+ def _response_items(body):
187
+ """解析列表接口的常见统一信封格式。"""
188
+ try:
189
+ raw = json.loads(body)
190
+ except Exception:
191
+ return []
192
+ if isinstance(raw, dict) and "data" in raw:
193
+ raw = raw["data"]
194
+ if isinstance(raw, list):
195
+ return raw
196
+ if isinstance(raw, dict):
197
+ for key in ("items", "results", "data"):
198
+ if isinstance(raw.get(key), list):
199
+ return raw[key]
200
+ return [raw]
201
+ return []
202
+
203
+
204
+ def _recover_missing_index_entries(
205
+ server, token, items, ids, id_key, type_name, rel_index, endpoint,
206
+ file_spec=None, drop_fields=(),
207
+ ):
208
+ """补齐缺失的 index 条目,且绝不覆盖本地受管内容。
209
+
210
+ 只有显式指定的目标才可恢复,防止 `push --all` 把不明文件自动纳管。
211
+ file_spec 为 (目录, index 文件字段, 文件名前缀, 扩展名列表)。
212
+ """
213
+ if not ids:
214
+ return items
215
+ wanted = [str(value) for value in ids]
216
+ registered = {str(item.get(id_key)) for item in items if item.get(id_key) is not None}
217
+ missing = [value for value in wanted if value not in registered]
218
+ if not missing:
219
+ return items
220
+ if DRY_RUN:
221
+ print(
222
+ f"ERR: {type_name} index.json 未登记目标 {id_key}: {', '.join(missing)}。"
223
+ "dry-run 不会回读云端或修改本地索引。",
224
+ file=sys.stderr,
225
+ )
226
+ sys.exit(1)
227
+
228
+ ok, status, body = api_call("GET", server, token, endpoint)
229
+ if not ok:
230
+ print(
231
+ f"ERR: 无法恢复 {type_name} index.json:云端回读失败 -> {_info(ok, status, body)}",
232
+ file=sys.stderr,
233
+ )
234
+ sys.exit(1)
235
+ remote_by_id = {
236
+ str(item.get(id_key)): item
237
+ for item in _response_items(body)
238
+ if isinstance(item, dict) and item.get(id_key) is not None
239
+ }
240
+ absent = [value for value in missing if value not in remote_by_id]
241
+ if absent:
242
+ print(
243
+ f"ERR: {type_name} 云端不存在目标 {id_key}: {', '.join(absent)}。"
244
+ "本地资源可能已成为孤儿文件,请确认后删除本地文件及其引用,"
245
+ "或改为新建资源推送。",
246
+ file=sys.stderr,
247
+ )
248
+ if file_spec:
249
+ rel_dir, _, prefix, exts = file_spec
250
+ hints = _orphan_hints(rel_dir, prefix, absent, exts)
251
+ for hint in hints:
252
+ print(f" 建议删除:{hint}", file=sys.stderr)
253
+ sys.exit(1)
254
+
255
+ recovered = []
256
+ for value in missing:
257
+ meta = dict(remote_by_id[value])
258
+ if file_spec:
259
+ rel_dir, file_field, prefix, exts = file_spec
260
+ candidates = _orphan_hints(rel_dir, prefix, [value], exts)
261
+ if len(candidates) != 1:
262
+ detail = "未找到" if not candidates else f"找到 {len(candidates)} 个"
263
+ print(
264
+ f"ERR: {type_name} {id_key}={value} 云端存在,但本地孤儿文件{detail},"
265
+ "无法安全补齐索引。请恢复唯一的本地文件或运行 pull 后重试。",
266
+ file=sys.stderr,
267
+ )
268
+ sys.exit(1)
269
+ meta[file_field] = candidates[0]
270
+ for field in drop_fields:
271
+ meta.pop(field, None)
272
+ items.append(meta)
273
+ recovered.append(value)
274
+
275
+ _writeback_index(rel_index, items)
276
+ print(f" OK {type_name}: 已从云端补齐 index.json 目标 {', '.join(recovered)},继续推送本地内容")
277
+ return items
278
+
279
+
280
+ def _orphan_hints(rel_dir, prefix, missing_ids, exts):
281
+ base = DEFAULT_ROOT / ".draftgo" / rel_dir
282
+ if not base.exists():
283
+ return []
284
+ hints = []
285
+ for mid in missing_ids:
286
+ for ext in exts:
287
+ for p in base.glob(f"{prefix}_{mid}_*{ext}"):
288
+ hints.append(f".draftgo/{rel_dir}/{p.name}")
289
+ return hints
290
+
291
+
292
+ def _warn_orphan_files(rel_dir, items, file_field, prefix, exts, type_name):
293
+ base = DEFAULT_ROOT / ".draftgo" / rel_dir
294
+ if not base.exists():
295
+ return
296
+ indexed = {str(it.get(file_field, "")).replace("\\", "/") for it in items}
297
+ orphans = []
298
+ for p in base.iterdir():
299
+ if not p.is_file():
300
+ continue
301
+ if not p.name.startswith(f"{prefix}_") or p.suffix not in exts:
302
+ continue
303
+ rel = f".draftgo/{rel_dir}/{p.name}"
304
+ if rel not in indexed:
305
+ orphans.append(rel)
306
+ if orphans:
307
+ print(f" WARN {type_name}: 发现 {len(orphans)} 个未登记到 index.json 的本地文件,本次不会推送:")
308
+ for rel in orphans[:8]:
309
+ print(f" - {rel}")
310
+
311
+
312
+ def _summary(type_name, targets):
313
+ print(f" SUMMARY {type_name}: targets={targets}")
314
+
315
+
316
+ def _preview(type_name, items, id_key="id"):
317
+ """打印不会产生云端或本地副作用的推送预览。"""
318
+ if not DRY_RUN:
319
+ return False
320
+ for item in items:
321
+ identity = item.get(id_key)
322
+ label = item.get("title") or item.get("name") or item.get("code") or identity or "(new)"
323
+ action = "UPDATE" if identity else "CREATE"
324
+ print(f" DRY-RUN {action} [{label}]")
325
+ _summary(type_name, len(items))
326
+ return True
327
+
328
+
199
329
  def sync_pages(server, token, ids=None):
200
- all_pages = _load_index("pages/index.json")
201
- pages = all_pages
202
- if ids:
203
- pages = [p for p in all_pages if str(p.get("id")) in ids]
330
+ all_pages = _load_index("pages/index.json", allow_missing=bool(ids))
331
+ all_pages = _recover_missing_index_entries(
332
+ server, token, all_pages, ids, "id", "pages", "pages/index.json", "/api/pages/",
333
+ file_spec=("pages", "html_file", "page", [".html"]), drop_fields=("value",),
334
+ )
335
+ _warn_orphan_files("pages", all_pages, "html_file", "page", [".html"], "pages")
336
+ pages = _filter_items_by_ids(
337
+ all_pages, ids, "id", "pages",
338
+ orphan=lambda missing: _orphan_hints("pages", "page", missing, [".html"]),
339
+ )
340
+ if _preview("pages", pages):
341
+ return
204
342
  dirty = False
205
343
  for page in pages:
206
344
  pid = page.get("id")
@@ -221,8 +359,6 @@ def sync_pages(server, token, ids=None):
221
359
  "value": {"html": html},
222
360
  }
223
361
  if pid:
224
- if not check_conflict(server, token, f"/api/pages/{pid}", page.get("updated_at"), title):
225
- continue
226
362
  ok, status, body = api_call("PUT", server, token, f"/api/pages/{pid}", payload)
227
363
  # PUT 404:本地 id 与云端不一致(删了重建 / 跨环境),按 route 自动创建
228
364
  if not ok and status == 404:
@@ -245,13 +381,17 @@ def sync_pages(server, token, ids=None):
245
381
  print(f" ERR [{title}] 创建失败 -> {_info(ok, status, body)}")
246
382
  if dirty:
247
383
  _writeback_index("pages/index.json", all_pages)
384
+ _summary("pages", len(pages))
248
385
 
249
386
 
250
387
  def sync_db_meta(server, token, ids=None):
251
- all_metas = _load_index("db_meta/index.json")
252
- metas = all_metas
253
- if ids:
254
- metas = [m for m in all_metas if str(m.get("id")) in ids]
388
+ all_metas = _load_index("db_meta/index.json", allow_missing=bool(ids))
389
+ all_metas = _recover_missing_index_entries(
390
+ server, token, all_metas, ids, "id", "db_meta", "db_meta/index.json", "/api/db-meta",
391
+ )
392
+ metas = _filter_items_by_ids(all_metas, ids, "id", "db_meta")
393
+ if _preview("db_meta", metas):
394
+ return
255
395
  dirty = False
256
396
  for meta in metas:
257
397
  mid = meta.get("id")
@@ -266,8 +406,6 @@ def sync_db_meta(server, token, ids=None):
266
406
  "extra": meta.get("extra"),
267
407
  }
268
408
  if mid:
269
- if not check_conflict(server, token, f"/api/db-meta/{mid}", meta.get("updated_at"), label):
270
- continue
271
409
  ok, status, body = api_call("PUT", server, token, f"/api/db-meta/{mid}", payload)
272
410
  # 本地 index 的 id 与云端不一致(删了重建 / 跨环境同步)时 PUT 404,按 type 创建
273
411
  if not ok and status == 404:
@@ -287,13 +425,22 @@ def sync_db_meta(server, token, ids=None):
287
425
  print(f" ERR [{label}] 创建失败 -> {_info(ok, status, body)}")
288
426
  if dirty:
289
427
  _writeback_index("db_meta/index.json", all_metas)
428
+ _summary("db_meta", len(metas))
290
429
 
291
430
 
292
431
  def sync_nav(server, token, ids=None):
293
- all_navs = _load_index("navigations/index.json")
294
- navs = all_navs
295
- if ids:
296
- navs = [n for n in all_navs if str(n.get("id")) in ids]
432
+ all_navs = _load_index("navigations/index.json", allow_missing=bool(ids))
433
+ all_navs = _recover_missing_index_entries(
434
+ server, token, all_navs, ids, "id", "nav", "navigations/index.json", "/api/navigations",
435
+ file_spec=("navigations", "html_file", "nav", [".html"]), drop_fields=("html",),
436
+ )
437
+ _warn_orphan_files("navigations", all_navs, "html_file", "nav", [".html"], "nav")
438
+ navs = _filter_items_by_ids(
439
+ all_navs, ids, "id", "nav",
440
+ orphan=lambda missing: _orphan_hints("navigations", "nav", missing, [".html"]),
441
+ )
442
+ if _preview("nav", navs):
443
+ return
297
444
  dirty = False
298
445
  for nav in navs:
299
446
  nid = nav.get("id")
@@ -304,8 +451,6 @@ def sync_nav(server, token, ids=None):
304
451
  continue
305
452
  html = html_file.read_text(encoding="utf-8")
306
453
  if nid:
307
- if not check_conflict(server, token, f"/api/navigations/{nid}", nav.get("updated_at"), name):
308
- continue
309
454
  ok, status, body = api_call("PUT", server, token, f"/api/navigations/{nid}", {"html": html})
310
455
  if not ok and status == 404:
311
456
  print(f" WARN [{name}] nav_id={nid} 不存在,尝试创建")
@@ -336,13 +481,17 @@ def sync_nav(server, token, ids=None):
336
481
  print(f" ERR [{name}] 创建失败 -> {_info(ok, status, body)}")
337
482
  if dirty:
338
483
  _writeback_index("navigations/index.json", all_navs)
484
+ _summary("nav", len(navs))
339
485
 
340
486
 
341
487
  def sync_aihub(server, token, ids=None):
342
- all_items = _load_index("aihub/index.json")
343
- items = all_items
344
- if ids:
345
- items = [it for it in all_items if str(it.get("id")) in ids]
488
+ all_items = _load_index("aihub/index.json", allow_missing=bool(ids))
489
+ all_items = _recover_missing_index_entries(
490
+ server, token, all_items, ids, "id", "aihub", "aihub/index.json", "/api/aihub",
491
+ )
492
+ items = _filter_items_by_ids(all_items, ids, "id", "aihub")
493
+ if _preview("aihub", items):
494
+ return
346
495
  dirty = False
347
496
  for it in items:
348
497
  iid = it.get("id")
@@ -353,8 +502,6 @@ def sync_aihub(server, token, ids=None):
353
502
  "tags", "describe", "permission", "status",
354
503
  ) if it.get(k) is not None}
355
504
  if iid:
356
- if not check_conflict(server, token, f"/api/aihub/{iid}", it.get("updated_at"), name):
357
- continue
358
505
  ok, status, body = api_call("PUT", server, token, f"/api/aihub/{iid}", payload)
359
506
  if not ok and status == 404:
360
507
  print(f" WARN [{name}] aihub_id={iid} 不存在,尝试创建")
@@ -376,61 +523,25 @@ def sync_aihub(server, token, ids=None):
376
523
  print(f" ERR [{name}] 创建失败 -> {_info(ok, status, body)}")
377
524
  if dirty:
378
525
  _writeback_index("aihub/index.json", all_items)
379
-
380
-
381
- def sync_external_apis(server, token, ids=None):
382
- all_items = _load_index("external_apis/index.json")
383
- items = all_items
384
- if ids:
385
- items = [it for it in all_items if str(it.get("id")) in ids]
386
- dirty = False
387
- for it in items:
388
- iid = it.get("id")
389
- code = it.get("code", iid or "(new)")
390
- # 服务端 ExternalAPIUpdate 接受字段
391
- payload = {k: it.get(k) for k in (
392
- "name", "base_url", "method", "path", "headers",
393
- "auth_type", "auth_config", "timeout_ms", "permission",
394
- "param_schema", "tags", "description", "status",
395
- ) if it.get(k) is not None}
396
- if iid:
397
- if not check_conflict(server, token, f"/api/external-apis/{iid}", it.get("updated_at"), code):
398
- continue
399
- ok, status, body = api_call("PUT", server, token, f"/api/external-apis/{iid}", payload)
400
- if not ok and status == 404:
401
- print(f" WARN [{code}] api_id={iid} 不存在,尝试创建")
402
- iid = None
403
- else:
404
- print(f" {'OK' if ok else 'ERR'} [{code}] api_id={iid}{'' if ok else ' -> ' + _info(ok, status, body)}")
405
- if not iid:
406
- create_payload = {k: it.get(k) for k in (
407
- "code", "name", "base_url", "method", "path", "headers",
408
- "auth_type", "auth_config", "timeout_ms", "permission",
409
- "param_schema", "tags", "description",
410
- ) if it.get(k) is not None}
411
- ok, status, body = api_call("POST", server, token, "/api/external-apis", create_payload)
412
- new_id = _extract_created_id(body) if ok else None
413
- if ok and new_id:
414
- it["id"] = new_id
415
- dirty = True
416
- print(f" OK [{code}] 已创建 api_id={new_id}(已回写 index)")
417
- else:
418
- print(f" ERR [{code}] 创建失败 -> {_info(ok, status, body)}")
419
- if dirty:
420
- _writeback_index("external_apis/index.json", all_items)
526
+ _summary("aihub", len(items))
421
527
 
422
528
 
423
529
  def sync_system_config(server, token, keys=None):
424
- items = _load_index("system_config/index.json")
425
- if keys:
426
- items = [it for it in items if str(it.get("config_key")) in keys]
530
+ items = _load_index("system_config/index.json", allow_missing=bool(keys))
531
+ items = _recover_missing_index_entries(
532
+ server, token, items, keys, "config_key", "system_config", "system_config/index.json", "/api/system/config",
533
+ )
534
+ items = _filter_items_by_ids(items, keys, "config_key", "system_config")
535
+ if _preview("system_config", items, "config_key"):
536
+ return
427
537
  for it in items:
428
538
  ck = it.get("config_key")
429
539
  if not ck:
430
540
  continue
431
541
  # 优先使用 parsed_value(init 时 GET 返回的解析值),其次 config_value
432
542
  value = it.get("parsed_value") if "parsed_value" in it else it.get("config_value")
433
- payload = {
543
+ value_payload = {"config_value": value}
544
+ meta_payload = {
434
545
  "config_value": value,
435
546
  "value_type": it.get("value_type"),
436
547
  "category": it.get("category"),
@@ -438,23 +549,66 @@ def sync_system_config(server, token, keys=None):
438
549
  "is_sensitive": it.get("is_sensitive"),
439
550
  "status": it.get("status"),
440
551
  }
441
- payload = {k: v for k, v in payload.items() if v is not None}
442
- if not check_conflict(server, token, f"/api/system/{ck}", it.get("updated_at"), ck):
443
- continue
552
+ meta_payload = {k: v for k, v in meta_payload.items() if v is not None}
553
+ # 前端全局层属于系统默认配置,默认字段的描述/分类/状态由基座维护。
554
+ # 推送时只更新值,避免旧 index 中的元信息触发“系统默认字段不允许修改字段描述”。
555
+ payload = value_payload if _is_frontend_global_config(it) else meta_payload
444
556
  ok, status, body = api_call("PUT", server, token, f"/api/system/{ck}", payload)
557
+ if not ok and _is_protected_system_config_error(body) and payload != value_payload:
558
+ print(f" WARN [{ck}] 系统默认字段元信息受保护,改为仅推送 config_value")
559
+ ok, status, body = api_call("PUT", server, token, f"/api/system/{ck}", value_payload)
445
560
  if not ok and status == 404:
446
561
  print(f" WARN [{ck}] system_config 不存在,尝试创建")
447
- create_payload = {"config_key": ck, **payload}
562
+ create_payload = _system_config_create_payload(ck, it, value)
448
563
  ok, status, body = api_call("POST", server, token, "/api/system/", create_payload)
449
564
  print(f" {'OK' if ok else 'ERR'} [{ck}] 已创建{'' if ok else ' -> ' + _info(ok, status, body)}")
450
565
  else:
451
566
  print(f" {'OK' if ok else 'ERR'} [{ck}]{'' if ok else ' -> ' + _info(ok, status, body)}")
567
+ _summary("system_config", len(items))
568
+
569
+
570
+ def _is_frontend_global_config(item):
571
+ return (
572
+ str(item.get("category") or "") == "frontend_global"
573
+ or str(item.get("config_key") or "").startswith("frontend_global_")
574
+ )
575
+
576
+
577
+ def _is_protected_system_config_error(body):
578
+ text = body if isinstance(body, str) else json.dumps(body, ensure_ascii=False)
579
+ return "系统默认字段不允许修改字段" in text
580
+
581
+
582
+ def _system_config_create_payload(config_key, item, value):
583
+ return {
584
+ "config_key": config_key,
585
+ "config_value": value,
586
+ "value_type": item.get("value_type") or _infer_system_config_value_type(value),
587
+ "category": item.get("category") or "custom",
588
+ "description": item.get("description") or "",
589
+ "is_sensitive": bool(item.get("is_sensitive", False)),
590
+ "status": item.get("status") or "active",
591
+ }
592
+
593
+
594
+ def _infer_system_config_value_type(value):
595
+ if isinstance(value, bool):
596
+ return "bool"
597
+ if isinstance(value, int) and not isinstance(value, bool):
598
+ return "int"
599
+ if isinstance(value, (dict, list)):
600
+ return "json"
601
+ return "string"
452
602
 
453
603
 
454
604
  def sync_roles(server, token, ids=None):
455
- items = _load_index("roles/index.json")
456
- if ids:
457
- items = [it for it in items if str(it.get("id")) in ids]
605
+ items = _load_index("roles/index.json", allow_missing=bool(ids))
606
+ items = _recover_missing_index_entries(
607
+ server, token, items, ids, "id", "roles", "roles/index.json", "/api/roles",
608
+ )
609
+ items = _filter_items_by_ids(items, ids, "id", "roles")
610
+ if _preview("roles", items):
611
+ return
458
612
  for it in items:
459
613
  rid = it.get("id")
460
614
  code = it.get("code", rid)
@@ -462,16 +616,19 @@ def sync_roles(server, token, ids=None):
462
616
  payload = {k: it.get(k) for k in (
463
617
  "name", "description", "status", "sort_order", "user_visible",
464
618
  ) if it.get(k) is not None}
465
- if not check_conflict(server, token, f"/api/roles/{rid}", it.get("updated_at"), code):
466
- continue
467
619
  ok, status, body = api_call("PUT", server, token, f"/api/roles/{rid}", payload)
468
620
  print(f" {'OK' if ok else 'ERR'} [{code}] role_id={rid}{'' if ok else ' -> ' + _info(ok, status, body)}")
621
+ _summary("roles", len(items))
469
622
 
470
623
 
471
624
  def sync_users(server, token, ids=None):
472
- items = _load_index("users/index.json")
473
- if ids:
474
- items = [it for it in items if str(it.get("id")) in ids]
625
+ items = _load_index("users/index.json", allow_missing=bool(ids))
626
+ items = _recover_missing_index_entries(
627
+ server, token, items, ids, "id", "users", "users/index.json", "/api/users",
628
+ )
629
+ items = _filter_items_by_ids(items, ids, "id", "users")
630
+ if _preview("users", items):
631
+ return
475
632
  for it in items:
476
633
  uid = it.get("id")
477
634
  uname = it.get("username", uid)
@@ -480,18 +637,25 @@ def sync_users(server, token, ids=None):
480
637
  "username", "email", "phone_number", "nickname",
481
638
  "avatar", "status", "notes",
482
639
  ) if it.get(k) is not None}
483
- if not check_conflict(server, token, f"/api/users/{uid}", it.get("updated_at"), uname):
484
- continue
485
640
  ok, status, body = api_call("PUT", server, token, f"/api/users/{uid}", payload)
486
641
  print(f" {'OK' if ok else 'ERR'} [{uname}] user_id={uid}{'' if ok else ' -> ' + _info(ok, status, body)}")
642
+ _summary("users", len(items))
487
643
 
488
644
 
489
645
  def sync_docs(server, token, ids=None):
490
646
  """文档文章:从 .draftgo/docs/articles/index.json + 同目录 .md 文件回填正文。"""
491
- all_items = _load_index("docs/articles/index.json")
492
- items = all_items
493
- if ids:
494
- items = [it for it in all_items if str(it.get("id")) in ids]
647
+ all_items = _load_index("docs/articles/index.json", allow_missing=bool(ids))
648
+ all_items = _recover_missing_index_entries(
649
+ server, token, all_items, ids, "id", "docs", "docs/articles/index.json", "/api/docs/admin/articles",
650
+ file_spec=("docs/articles", "content_file", "article", [".html", ".md"]), drop_fields=("content",),
651
+ )
652
+ _warn_orphan_files("docs/articles", all_items, "content_file", "article", [".html", ".md"], "docs")
653
+ items = _filter_items_by_ids(
654
+ all_items, ids, "id", "docs",
655
+ orphan=lambda missing: _orphan_hints("docs/articles", "article", missing, [".html", ".md"]),
656
+ )
657
+ if _preview("docs", items):
658
+ return
495
659
  dirty = False
496
660
  for it in items:
497
661
  aid = it.get("id")
@@ -509,9 +673,8 @@ def sync_docs(server, token, ids=None):
509
673
  "permission",
510
674
  ) if it.get(k) is not None}
511
675
  payload["content"] = content
676
+ payload["content_type"] = "html"
512
677
  if aid:
513
- if not check_conflict(server, token, f"/api/docs/articles/{aid}", it.get("updated_at"), title):
514
- continue
515
678
  ok, status, body = api_call("PUT", server, token, f"/api/docs/articles/{aid}", payload)
516
679
  if not ok and status == 404:
517
680
  print(f" WARN [{title}] article_id={aid} 不存在,尝试创建")
@@ -525,7 +688,7 @@ def sync_docs(server, token, ids=None):
525
688
  it["id"] = new_id
526
689
  new_rel = _rename_to_canonical(
527
690
  "docs/articles", it.get("content_file", ""), "article", new_id,
528
- it.get("slug") or it.get("title") or new_id, "md")
691
+ it.get("slug") or it.get("title") or new_id, "html")
529
692
  it["content_file"] = new_rel
530
693
  dirty = True
531
694
  print(f" OK [{title}] 已创建 article_id={new_id}(已回写 index)")
@@ -533,13 +696,17 @@ def sync_docs(server, token, ids=None):
533
696
  print(f" ERR [{title}] 创建失败 -> {_info(ok, status, body)}")
534
697
  if dirty:
535
698
  _writeback_index("docs/articles/index.json", all_items)
699
+ _summary("docs", len(items))
536
700
 
537
701
 
538
702
  def sync_doc_categories(server, token, ids=None):
539
- all_items = _load_index("doc_categories/index.json")
540
- items = all_items
541
- if ids:
542
- items = [it for it in all_items if str(it.get("id")) in ids]
703
+ all_items = _load_index("doc_categories/index.json", allow_missing=bool(ids))
704
+ all_items = _recover_missing_index_entries(
705
+ server, token, all_items, ids, "id", "doc_categories", "doc_categories/index.json", "/api/docs/categories?flat=true",
706
+ )
707
+ items = _filter_items_by_ids(all_items, ids, "id", "doc_categories")
708
+ if _preview("doc_categories", items):
709
+ return
543
710
  dirty = False
544
711
  for it in items:
545
712
  cid = it.get("id")
@@ -549,8 +716,6 @@ def sync_doc_categories(server, token, ids=None):
549
716
  "name", "slug", "description", "icon", "parent_id", "sort_order", "status",
550
717
  ) if it.get(k) is not None}
551
718
  if cid:
552
- if not check_conflict(server, token, f"/api/docs/categories/{cid}", it.get("updated_at"), name):
553
- continue
554
719
  ok, status, body = api_call("PUT", server, token, f"/api/docs/categories/{cid}", payload)
555
720
  if not ok and status == 404:
556
721
  print(f" WARN [{name}] category_id={cid} 不存在,尝试创建")
@@ -568,26 +733,45 @@ def sync_doc_categories(server, token, ids=None):
568
733
  print(f" ERR [{name}] 创建失败 -> {_info(ok, status, body)}")
569
734
  if dirty:
570
735
  _writeback_index("doc_categories/index.json", all_items)
736
+ _summary("doc_categories", len(items))
571
737
 
572
738
 
573
- def _normalize_script_triggers(mode, triggers):
574
- """归一化 custom_script.triggers。
739
+ def _route_specs_from_code(code):
740
+ specs = []
741
+ for m in re.finditer(r'@route\(\s*["\']([A-Za-z]+)\s+([^"\']+)["\']\s*\)', code):
742
+ specs.append((m.group(1).upper(), m.group(2)))
743
+ for m in re.finditer(r'\.Route\(\s*["\']([A-Za-z]+)["\']\s*,\s*["\']([^"\']+)["\']', code):
744
+ specs.append((m.group(1).upper(), m.group(2)))
745
+ return specs
575
746
 
576
- - scheduled:实际调度只读代码里的 @scheduled(...),CLI push 主动回写空 dict,
577
- 避免把误导性的 cron 元数据继续推回云端。
578
- - 其他模式:保留本地 triggers 元数据。
579
- """
580
- if str(mode or "").lower() == "scheduled":
581
- return {}
582
- return triggers
747
+
748
+ def _probe_script_routes(server, token, script_meta, code):
749
+ slug = script_meta.get("slug")
750
+ if not slug:
751
+ return
752
+ for method, route_path in _route_specs_from_code(code):
753
+ if method != "GET":
754
+ print(f" SKIP probe [{slug}] {method} {route_path}(只自动探测 GET,避免副作用)")
755
+ continue
756
+ path = route_path if route_path.startswith("/") else f"/{route_path}"
757
+ ok, status, body = api_call("GET", server, token, f"/api/x/{slug}{path}")
758
+ print(f" {'OK' if ok else 'ERR'} probe GET /api/x/{slug}{path}{'' if ok else ' -> ' + _info(ok, status, body)}")
583
759
 
584
760
 
585
761
  def sync_custom_scripts(server, token, ids=None):
586
762
  """自定义脚本:从 .draftgo/custom_scripts/index.json + 同目录代码文件回填 code。"""
587
- all_items = _load_index("custom_scripts/index.json")
588
- items = all_items
589
- if ids:
590
- items = [it for it in all_items if str(it.get("id")) in ids]
763
+ all_items = _load_index("custom_scripts/index.json", allow_missing=bool(ids))
764
+ all_items = _recover_missing_index_entries(
765
+ server, token, all_items, ids, "id", "custom_scripts", "custom_scripts/index.json", "/api/scripts/",
766
+ file_spec=("custom_scripts", "code_file", "script", [".py", ".js", ".ts", ".sh", ".go", ".txt"]), drop_fields=("code",),
767
+ )
768
+ _warn_orphan_files("custom_scripts", all_items, "code_file", "script", [".go"], "custom_scripts")
769
+ items = _filter_items_by_ids(
770
+ all_items, ids, "id", "custom_scripts",
771
+ orphan=lambda missing: _orphan_hints("custom_scripts", "script", missing, [".go"]),
772
+ )
773
+ if _preview("custom_scripts", items):
774
+ return
591
775
  dirty = False
592
776
  for it in items:
593
777
  sid = it.get("id")
@@ -598,15 +782,11 @@ def sync_custom_scripts(server, token, ids=None):
598
782
  print(f" SKIP [{name}] code_file not found: {code_rel}")
599
783
  continue
600
784
  code = code_path.read_text(encoding="utf-8")
601
- normalized_triggers = _normalize_script_triggers(it.get("mode"), it.get("triggers"))
602
785
  if sid:
603
- if not check_conflict(server, token, f"/api/scripts/{sid}", it.get("updated_at"), name):
604
- continue
605
786
  # ScriptUpdate 接受字段(不含 slug/mode,避免误改启停/路由)
606
787
  payload = {k: it.get(k) for k in (
607
- "name", "description", "config", "permission",
788
+ "name", "description", "config", "permission", "go_mod", "go_sum",
608
789
  ) if it.get(k) is not None}
609
- payload["triggers"] = normalized_triggers
610
790
  payload["code"] = code
611
791
  ok, status, body = api_call("PUT", server, token, f"/api/scripts/{sid}", payload)
612
792
  if not ok and status == 404:
@@ -614,17 +794,20 @@ def sync_custom_scripts(server, token, ids=None):
614
794
  sid = None
615
795
  else:
616
796
  print(f" {'OK' if ok else 'ERR'} [{name}] script_id={sid}{'' if ok else ' -> ' + _info(ok, status, body)}")
797
+ if ok and PROBE_ROUTES and str(it.get("mode") or "").lower() in ("route", "mixed"):
798
+ _probe_script_routes(server, token, it, code)
617
799
  if not sid:
618
- # ScriptCreate 必填 name/slug/code/mode;scheduled 的 cron 以代码装饰器为准
800
+ # ScriptCreate 必填 name/slug/code/mode;触发来源统一以代码装饰器为准
619
801
  payload = {
620
802
  "name": it.get("name", ""),
621
803
  "slug": it.get("slug", ""),
622
804
  "code": code,
623
805
  "mode": it.get("mode", "route"),
624
- "triggers": normalized_triggers,
625
806
  "config": it.get("config"),
626
807
  "permission": it.get("permission"),
627
808
  "description": it.get("description"),
809
+ "go_mod": it.get("go_mod"),
810
+ "go_sum": it.get("go_sum"),
628
811
  }
629
812
  payload = {k: v for k, v in payload.items() if v is not None}
630
813
  ok, status, body = api_call("POST", server, token, "/api/scripts/", payload)
@@ -638,10 +821,13 @@ def sync_custom_scripts(server, token, ids=None):
638
821
  it["code_file"] = new_rel
639
822
  dirty = True
640
823
  print(f" OK [{name}] 已创建 script_id={new_id}(已回写 index)")
824
+ if PROBE_ROUTES and str(it.get("mode") or "").lower() in ("route", "mixed"):
825
+ _probe_script_routes(server, token, it, code)
641
826
  else:
642
827
  print(f" ERR [{name}] 创建失败 -> {_info(ok, status, body)}")
643
828
  if dirty:
644
829
  _writeback_index("custom_scripts/index.json", all_items)
830
+ _summary("custom_scripts", len(items))
645
831
 
646
832
 
647
833
  def _lint_page_html(html, label):
@@ -693,7 +879,6 @@ HANDLERS = {
693
879
  "nav": sync_nav,
694
880
  "db_meta": sync_db_meta,
695
881
  "aihub": sync_aihub,
696
- "external_apis": sync_external_apis,
697
882
  "system_config": sync_system_config,
698
883
  "roles": sync_roles,
699
884
  "users": sync_users,
@@ -723,28 +908,32 @@ def run_batch(server, token, args):
723
908
  for mode, ids in groups:
724
909
  print(f"\n--- {mode} {'(all)' if not ids else ','.join(ids)} ---")
725
910
  HANDLERS[mode](server, token, ids)
726
- print(f"\n[batch] done, {len(groups)} group(s) pushed")
911
+ result = "previewed" if DRY_RUN else "pushed"
912
+ print(f"\n[batch] done, {len(groups)} group(s) {result}")
727
913
 
728
914
 
729
915
  def main():
730
- global FORCE
731
-
916
+ global PROBE_ROUTES, DRY_RUN
732
917
  if len(sys.argv) < 2:
733
918
  print(f"usage: draftgo_push.py {{{'|'.join(HANDLERS)}}} [id ...]\n"
734
- f" draftgo_push.py --batch <mode> <ids> [<mode> <ids> ...]\n"
735
- f" --force 跳过冲突检测,强制覆盖")
919
+ f" draftgo_push.py --batch <mode> <ids> [<mode> <ids> ...]")
736
920
  sys.exit(1)
737
921
 
738
- # 解析 --force(可出现在任意位置)
739
- args = [a for a in sys.argv[1:] if a != "--force"]
740
- if len(args) < len(sys.argv) - 1:
741
- FORCE = True
742
- print("[force] 已跳过冲突检测,将强制覆盖云端数据")
922
+ args = sys.argv[1:]
923
+ if "--probe-routes" in args:
924
+ PROBE_ROUTES = True
925
+ args = [a for a in args if a != "--probe-routes"]
926
+ if "--dry-run" in args:
927
+ DRY_RUN = True
928
+ args = [a for a in args if a != "--dry-run"]
743
929
 
744
930
  if not args:
745
931
  print(f"usage: draftgo_push.py {{{'|'.join(HANDLERS)}}} [id ...]")
746
932
  sys.exit(1)
747
933
 
934
+ if args[0] == "--all":
935
+ args = ["--batch", *HANDLERS]
936
+
748
937
  if args[0] == "--batch":
749
938
  server, token, cfg = load_config()
750
939
  run_batch(server, token, args[1:])