draftgo-cli 3.0.1 → 3.0.33

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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -124
  3. package/package.json +21 -8
  4. package/resources/skill/SKILL.md +62 -89
  5. package/resources/skill/init/SKILL.md +18 -67
  6. package/resources/skill/manifest.json +27 -0
  7. package/resources/skill/pull/SKILL.md +18 -44
  8. package/resources/skill/push/SKILL.md +30 -247
  9. package/resources/skill/references/aihub.md +86 -0
  10. package/resources/skill/references/api-endpoints.md +178 -0
  11. package/resources/skill/references/api.json +20248 -0
  12. package/resources/skill/{quickref → references}/app-api.md +44 -14
  13. package/resources/skill/{core → references}/architecture.md +6 -26
  14. package/resources/skill/references/chat-sdk.md +201 -0
  15. package/resources/skill/references/custom-services.md +308 -0
  16. package/resources/skill/references/data.md +298 -0
  17. package/resources/skill/references/db-relations.md +227 -0
  18. package/resources/skill/references/frontend.md +788 -0
  19. package/resources/skill/references/modules.md +66 -0
  20. package/resources/skill/references/parallel.md +48 -0
  21. package/resources/skill/{specs → references}/runtime.md +31 -1
  22. package/resources/skill/{specs → references}/security.md +3 -3
  23. package/resources/skill/references/ui-protocol.md +99 -0
  24. package/resources/skill/scripts/draftgo_delete.py +0 -2
  25. package/resources/skill/scripts/draftgo_init.py +15 -3
  26. package/resources/skill/scripts/draftgo_pull.py +154 -87
  27. package/resources/skill/scripts/draftgo_push.py +440 -183
  28. package/resources/skill/story/SKILL.md +13 -23
  29. package/src/cli.js +22 -7
  30. package/src/commandRegistry.js +34 -0
  31. package/src/commands/api.js +204 -0
  32. package/src/commands/autoPush.js +41 -0
  33. package/src/commands/check.js +27 -17
  34. package/src/commands/delete.js +6 -4
  35. package/src/commands/deploy.js +31 -0
  36. package/src/commands/help.js +41 -28
  37. package/src/commands/init.js +34 -20
  38. package/src/commands/local.js +9 -3
  39. package/src/commands/map.js +18 -7
  40. package/src/commands/sync.js +11 -4
  41. package/src/commands/update.js +39 -52
  42. package/src/commands/verifyUi.js +199 -0
  43. package/src/index.js +13 -46
  44. package/src/localdev/compose.js +48 -197
  45. package/src/localdev/index.js +116 -216
  46. package/src/localdev/mysqlClient.js +12 -9
  47. package/src/localdev/services.js +163 -0
  48. package/src/platforms.js +3 -3
  49. package/src/projectConfig.js +12 -2
  50. package/src/projectMap.js +240 -68
  51. package/src/skill.js +113 -29
  52. package/src/updateCheck.js +37 -15
  53. package/resources/skill/core/modules.md +0 -54
  54. package/resources/skill/practices/anti-patterns.md +0 -70
  55. package/resources/skill/practices/best-practices.md +0 -41
  56. package/resources/skill/practices/dev-declaration.md +0 -94
  57. package/resources/skill/quickref/api-endpoints.md +0 -130
  58. package/resources/skill/quickref/api.json +0 -17675
  59. package/resources/skill/quickref/dg-components.md +0 -198
  60. package/resources/skill/rules/dev-workflow.md +0 -652
  61. package/resources/skill/rules/frontend.md +0 -210
  62. package/resources/skill/rules/parallel.md +0 -263
  63. package/resources/skill/specs/data.md +0 -108
  64. package/resources/skill/specs/ui-protocol.md +0 -68
  65. package/src/commands/doctor.js +0 -54
  66. package/src/commands/new.js +0 -183
  67. package/src/commands/projectScript.js +0 -37
  68. /package/resources/skill/{rules → references}/debugging-syntax.md +0 -0
@@ -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,21 @@ 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
- import json, sys, re
20
+ import json, sys, re, datetime
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
27
+ RUN_FAILURES = 0
26
28
 
27
29
 
28
30
  def find_project_root(start: Path) -> Path:
@@ -43,46 +45,6 @@ def find_project_root(start: Path) -> Path:
43
45
 
44
46
  DEFAULT_ROOT = find_project_root(SCRIPT_DIR)
45
47
 
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
48
  def load_config():
87
49
  cfg_path = DEFAULT_ROOT / ".draftgo/config.json"
88
50
  if not cfg_path.exists():
@@ -93,6 +55,8 @@ def load_config():
93
55
 
94
56
 
95
57
  def _lessons_reminder(cfg):
58
+ if DRY_RUN:
59
+ return
96
60
  if not cfg.get("lessons_on_push", False):
97
61
  return
98
62
  print("")
@@ -104,7 +68,9 @@ def _lessons_reminder(cfg):
104
68
  print(" 未命中则跳过。")
105
69
 
106
70
 
107
- def api_call(method, server, token, path, body=None):
71
+ def api_call(method, server, token, path, body=None, acceptable_errors=()):
72
+ """发起 API 调用,并记录调用方不会恢复的失败。"""
73
+ global RUN_FAILURES
108
74
  data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
109
75
  headers = {
110
76
  "Authorization": f"Bearer {token}",
@@ -126,8 +92,12 @@ def api_call(method, server, token, path, body=None):
126
92
  with urllib.request.urlopen(req, timeout=15) as r:
127
93
  return True, r.status, r.read().decode("utf-8", errors="replace")
128
94
  except urllib.error.HTTPError as e:
129
- return False, e.code, e.read().decode(errors="replace")
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
130
99
  except Exception as e:
100
+ RUN_FAILURES += 1
131
101
  return False, None, str(e)
132
102
 
133
103
 
@@ -159,8 +129,9 @@ def _extract_created_id(body_text):
159
129
  def _writeback_index(rel_index, items):
160
130
  """将更新后的 index 列表写回 .draftgo/ 下的 index.json。"""
161
131
  path = DEFAULT_ROOT / ".draftgo" / rel_index
162
- if not path.exists():
132
+ if not path.exists() and not (DEFAULT_ROOT / ".draftgo").exists():
163
133
  path = DEFAULT_ROOT / rel_index
134
+ path.parent.mkdir(parents=True, exist_ok=True)
164
135
  path.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8")
165
136
 
166
137
 
@@ -185,22 +156,247 @@ def _rename_to_canonical(sub_dir, old_rel, prefix, new_id, slug_src, ext):
185
156
  return old_rel
186
157
 
187
158
 
188
- def _load_index(rel_index):
159
+ def _load_index(rel_index, allow_missing=False):
189
160
  """读取 .draftgo/ 下的 index.json;支持旧 (pages/index.json) 与新 (.draftgo/pages/index.json) 两种布局。"""
190
161
  new_path = DEFAULT_ROOT / ".draftgo" / rel_index
191
162
  legacy_path = DEFAULT_ROOT / rel_index
192
163
  path = new_path if new_path.exists() else legacy_path
193
164
  if not path.exists():
165
+ if allow_missing:
166
+ print(f" WARN {rel_index} 不存在,将尝试从云端恢复指定目标的索引")
167
+ return []
194
168
  print(f"ERR: {rel_index} not found under .draftgo/, run /draftgo init first", file=sys.stderr)
195
169
  sys.exit(1)
196
170
  return json.loads(path.read_text(encoding="utf-8"))
197
171
 
198
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
+
199
387
  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]
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
204
400
  dirty = False
205
401
  for page in pages:
206
402
  pid = page.get("id")
@@ -221,9 +417,9 @@ def sync_pages(server, token, ids=None):
221
417
  "value": {"html": html},
222
418
  }
223
419
  if pid:
224
- if not check_conflict(server, token, f"/api/pages/{pid}", page.get("updated_at"), title):
420
+ if not _cloud_version_allows_push(server, token, f"/api/pages/{pid}", page, title):
225
421
  continue
226
- ok, status, body = api_call("PUT", server, token, f"/api/pages/{pid}", payload)
422
+ ok, status, body = api_call("PUT", server, token, f"/api/pages/{pid}", payload, acceptable_errors=(404,))
227
423
  # PUT 404:本地 id 与云端不一致(删了重建 / 跨环境),按 route 自动创建
228
424
  if not ok and status == 404:
229
425
  print(f" WARN [{title}] page_id={pid} 不存在,尝试创建")
@@ -245,13 +441,17 @@ def sync_pages(server, token, ids=None):
245
441
  print(f" ERR [{title}] 创建失败 -> {_info(ok, status, body)}")
246
442
  if dirty:
247
443
  _writeback_index("pages/index.json", all_pages)
444
+ _summary("pages", len(pages))
248
445
 
249
446
 
250
447
  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]
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
255
455
  dirty = False
256
456
  for meta in metas:
257
457
  mid = meta.get("id")
@@ -266,9 +466,7 @@ def sync_db_meta(server, token, ids=None):
266
466
  "extra": meta.get("extra"),
267
467
  }
268
468
  if mid:
269
- if not check_conflict(server, token, f"/api/db-meta/{mid}", meta.get("updated_at"), label):
270
- continue
271
- ok, status, body = api_call("PUT", server, token, f"/api/db-meta/{mid}", payload)
469
+ ok, status, body = api_call("PUT", server, token, f"/api/db-meta/{mid}", payload, acceptable_errors=(404,))
272
470
  # 本地 index 的 id 与云端不一致(删了重建 / 跨环境同步)时 PUT 404,按 type 创建
273
471
  if not ok and status == 404:
274
472
  print(f" WARN [{label}] db_meta_id={mid} 不存在,尝试按 type 创建")
@@ -287,13 +485,22 @@ def sync_db_meta(server, token, ids=None):
287
485
  print(f" ERR [{label}] 创建失败 -> {_info(ok, status, body)}")
288
486
  if dirty:
289
487
  _writeback_index("db_meta/index.json", all_metas)
488
+ _summary("db_meta", len(metas))
290
489
 
291
490
 
292
491
  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]
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
297
504
  dirty = False
298
505
  for nav in navs:
299
506
  nid = nav.get("id")
@@ -304,9 +511,7 @@ def sync_nav(server, token, ids=None):
304
511
  continue
305
512
  html = html_file.read_text(encoding="utf-8")
306
513
  if nid:
307
- if not check_conflict(server, token, f"/api/navigations/{nid}", nav.get("updated_at"), name):
308
- continue
309
- ok, status, body = api_call("PUT", server, token, f"/api/navigations/{nid}", {"html": html})
514
+ ok, status, body = api_call("PUT", server, token, f"/api/navigations/{nid}", {"html": html}, acceptable_errors=(404,))
310
515
  if not ok and status == 404:
311
516
  print(f" WARN [{name}] nav_id={nid} 不存在,尝试创建")
312
517
  nid = None
@@ -336,13 +541,17 @@ def sync_nav(server, token, ids=None):
336
541
  print(f" ERR [{name}] 创建失败 -> {_info(ok, status, body)}")
337
542
  if dirty:
338
543
  _writeback_index("navigations/index.json", all_navs)
544
+ _summary("nav", len(navs))
339
545
 
340
546
 
341
547
  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]
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
346
555
  dirty = False
347
556
  for it in items:
348
557
  iid = it.get("id")
@@ -353,9 +562,7 @@ def sync_aihub(server, token, ids=None):
353
562
  "tags", "describe", "permission", "status",
354
563
  ) if it.get(k) is not None}
355
564
  if iid:
356
- if not check_conflict(server, token, f"/api/aihub/{iid}", it.get("updated_at"), name):
357
- continue
358
- ok, status, body = api_call("PUT", server, token, f"/api/aihub/{iid}", payload)
565
+ ok, status, body = api_call("PUT", server, token, f"/api/aihub/{iid}", payload, acceptable_errors=(404,))
359
566
  if not ok and status == 404:
360
567
  print(f" WARN [{name}] aihub_id={iid} 不存在,尝试创建")
361
568
  iid = None
@@ -376,61 +583,25 @@ def sync_aihub(server, token, ids=None):
376
583
  print(f" ERR [{name}] 创建失败 -> {_info(ok, status, body)}")
377
584
  if dirty:
378
585
  _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)
586
+ _summary("aihub", len(items))
421
587
 
422
588
 
423
589
  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]
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
427
597
  for it in items:
428
598
  ck = it.get("config_key")
429
599
  if not ck:
430
600
  continue
431
601
  # 优先使用 parsed_value(init 时 GET 返回的解析值),其次 config_value
432
602
  value = it.get("parsed_value") if "parsed_value" in it else it.get("config_value")
433
- payload = {
603
+ value_payload = {"config_value": value}
604
+ meta_payload = {
434
605
  "config_value": value,
435
606
  "value_type": it.get("value_type"),
436
607
  "category": it.get("category"),
@@ -438,23 +609,66 @@ def sync_system_config(server, token, keys=None):
438
609
  "is_sensitive": it.get("is_sensitive"),
439
610
  "status": it.get("status"),
440
611
  }
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
444
- ok, status, body = api_call("PUT", server, token, f"/api/system/{ck}", payload)
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,))
445
620
  if not ok and status == 404:
446
621
  print(f" WARN [{ck}] system_config 不存在,尝试创建")
447
- create_payload = {"config_key": ck, **payload}
622
+ create_payload = _system_config_create_payload(ck, it, value)
448
623
  ok, status, body = api_call("POST", server, token, "/api/system/", create_payload)
449
624
  print(f" {'OK' if ok else 'ERR'} [{ck}] 已创建{'' if ok else ' -> ' + _info(ok, status, body)}")
450
625
  else:
451
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"
452
662
 
453
663
 
454
664
  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]
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
458
672
  for it in items:
459
673
  rid = it.get("id")
460
674
  code = it.get("code", rid)
@@ -462,16 +676,19 @@ def sync_roles(server, token, ids=None):
462
676
  payload = {k: it.get(k) for k in (
463
677
  "name", "description", "status", "sort_order", "user_visible",
464
678
  ) 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
679
  ok, status, body = api_call("PUT", server, token, f"/api/roles/{rid}", payload)
468
680
  print(f" {'OK' if ok else 'ERR'} [{code}] role_id={rid}{'' if ok else ' -> ' + _info(ok, status, body)}")
681
+ _summary("roles", len(items))
469
682
 
470
683
 
471
684
  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]
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
475
692
  for it in items:
476
693
  uid = it.get("id")
477
694
  uname = it.get("username", uid)
@@ -480,18 +697,25 @@ def sync_users(server, token, ids=None):
480
697
  "username", "email", "phone_number", "nickname",
481
698
  "avatar", "status", "notes",
482
699
  ) 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
700
  ok, status, body = api_call("PUT", server, token, f"/api/users/{uid}", payload)
486
701
  print(f" {'OK' if ok else 'ERR'} [{uname}] user_id={uid}{'' if ok else ' -> ' + _info(ok, status, body)}")
702
+ _summary("users", len(items))
487
703
 
488
704
 
489
705
  def sync_docs(server, token, ids=None):
490
706
  """文档文章:从 .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]
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
495
719
  dirty = False
496
720
  for it in items:
497
721
  aid = it.get("id")
@@ -509,10 +733,9 @@ def sync_docs(server, token, ids=None):
509
733
  "permission",
510
734
  ) if it.get(k) is not None}
511
735
  payload["content"] = content
736
+ payload["content_type"] = "html"
512
737
  if aid:
513
- if not check_conflict(server, token, f"/api/docs/articles/{aid}", it.get("updated_at"), title):
514
- continue
515
- ok, status, body = api_call("PUT", server, token, f"/api/docs/articles/{aid}", payload)
738
+ ok, status, body = api_call("PUT", server, token, f"/api/docs/articles/{aid}", payload, acceptable_errors=(404,))
516
739
  if not ok and status == 404:
517
740
  print(f" WARN [{title}] article_id={aid} 不存在,尝试创建")
518
741
  aid = None
@@ -525,7 +748,7 @@ def sync_docs(server, token, ids=None):
525
748
  it["id"] = new_id
526
749
  new_rel = _rename_to_canonical(
527
750
  "docs/articles", it.get("content_file", ""), "article", new_id,
528
- it.get("slug") or it.get("title") or new_id, "md")
751
+ it.get("slug") or it.get("title") or new_id, "html")
529
752
  it["content_file"] = new_rel
530
753
  dirty = True
531
754
  print(f" OK [{title}] 已创建 article_id={new_id}(已回写 index)")
@@ -533,13 +756,17 @@ def sync_docs(server, token, ids=None):
533
756
  print(f" ERR [{title}] 创建失败 -> {_info(ok, status, body)}")
534
757
  if dirty:
535
758
  _writeback_index("docs/articles/index.json", all_items)
759
+ _summary("docs", len(items))
536
760
 
537
761
 
538
762
  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]
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
543
770
  dirty = False
544
771
  for it in items:
545
772
  cid = it.get("id")
@@ -549,9 +776,7 @@ def sync_doc_categories(server, token, ids=None):
549
776
  "name", "slug", "description", "icon", "parent_id", "sort_order", "status",
550
777
  ) if it.get(k) is not None}
551
778
  if cid:
552
- if not check_conflict(server, token, f"/api/docs/categories/{cid}", it.get("updated_at"), name):
553
- continue
554
- ok, status, body = api_call("PUT", server, token, f"/api/docs/categories/{cid}", payload)
779
+ ok, status, body = api_call("PUT", server, token, f"/api/docs/categories/{cid}", payload, acceptable_errors=(404,))
555
780
  if not ok and status == 404:
556
781
  print(f" WARN [{name}] category_id={cid} 不存在,尝试创建")
557
782
  cid = None
@@ -568,26 +793,45 @@ def sync_doc_categories(server, token, ids=None):
568
793
  print(f" ERR [{name}] 创建失败 -> {_info(ok, status, body)}")
569
794
  if dirty:
570
795
  _writeback_index("doc_categories/index.json", all_items)
796
+ _summary("doc_categories", len(items))
571
797
 
572
798
 
573
- def _normalize_script_triggers(mode, triggers):
574
- """归一化 custom_script.triggers。
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
575
806
 
576
- - scheduled:实际调度只读代码里的 @scheduled(...),CLI push 主动回写空 dict,
577
- 避免把误导性的 cron 元数据继续推回云端。
578
- - 其他模式:保留本地 triggers 元数据。
579
- """
580
- if str(mode or "").lower() == "scheduled":
581
- return {}
582
- return triggers
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)}")
583
819
 
584
820
 
585
821
  def sync_custom_scripts(server, token, ids=None):
586
822
  """自定义脚本:从 .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]
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
591
835
  dirty = False
592
836
  for it in items:
593
837
  sid = it.get("id")
@@ -598,33 +842,34 @@ def sync_custom_scripts(server, token, ids=None):
598
842
  print(f" SKIP [{name}] code_file not found: {code_rel}")
599
843
  continue
600
844
  code = code_path.read_text(encoding="utf-8")
601
- normalized_triggers = _normalize_script_triggers(it.get("mode"), it.get("triggers"))
602
845
  if sid:
603
- if not check_conflict(server, token, f"/api/scripts/{sid}", it.get("updated_at"), name):
846
+ if not _cloud_version_allows_push(server, token, f"/api/scripts/{sid}", it, name):
604
847
  continue
605
848
  # ScriptUpdate 接受字段(不含 slug/mode,避免误改启停/路由)
606
849
  payload = {k: it.get(k) for k in (
607
- "name", "description", "config", "permission",
850
+ "name", "description", "config", "permission", "go_mod", "go_sum",
608
851
  ) if it.get(k) is not None}
609
- payload["triggers"] = normalized_triggers
610
852
  payload["code"] = code
611
- ok, status, body = api_call("PUT", server, token, f"/api/scripts/{sid}", payload)
853
+ ok, status, body = api_call("PUT", server, token, f"/api/scripts/{sid}", payload, acceptable_errors=(404,))
612
854
  if not ok and status == 404:
613
855
  print(f" WARN [{name}] script_id={sid} 不存在,尝试创建")
614
856
  sid = None
615
857
  else:
616
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)
617
861
  if not sid:
618
- # ScriptCreate 必填 name/slug/code/mode;scheduled 的 cron 以代码装饰器为准
862
+ # ScriptCreate 必填 name/slug/code/mode;触发来源统一以代码装饰器为准
619
863
  payload = {
620
864
  "name": it.get("name", ""),
621
865
  "slug": it.get("slug", ""),
622
866
  "code": code,
623
867
  "mode": it.get("mode", "route"),
624
- "triggers": normalized_triggers,
625
868
  "config": it.get("config"),
626
869
  "permission": it.get("permission"),
627
870
  "description": it.get("description"),
871
+ "go_mod": it.get("go_mod"),
872
+ "go_sum": it.get("go_sum"),
628
873
  }
629
874
  payload = {k: v for k, v in payload.items() if v is not None}
630
875
  ok, status, body = api_call("POST", server, token, "/api/scripts/", payload)
@@ -638,10 +883,13 @@ def sync_custom_scripts(server, token, ids=None):
638
883
  it["code_file"] = new_rel
639
884
  dirty = True
640
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)
641
888
  else:
642
889
  print(f" ERR [{name}] 创建失败 -> {_info(ok, status, body)}")
643
890
  if dirty:
644
891
  _writeback_index("custom_scripts/index.json", all_items)
892
+ _summary("custom_scripts", len(items))
645
893
 
646
894
 
647
895
  def _lint_page_html(html, label):
@@ -693,7 +941,6 @@ HANDLERS = {
693
941
  "nav": sync_nav,
694
942
  "db_meta": sync_db_meta,
695
943
  "aihub": sync_aihub,
696
- "external_apis": sync_external_apis,
697
944
  "system_config": sync_system_config,
698
945
  "roles": sync_roles,
699
946
  "users": sync_users,
@@ -723,32 +970,39 @@ def run_batch(server, token, args):
723
970
  for mode, ids in groups:
724
971
  print(f"\n--- {mode} {'(all)' if not ids else ','.join(ids)} ---")
725
972
  HANDLERS[mode](server, token, ids)
726
- print(f"\n[batch] done, {len(groups)} group(s) pushed")
973
+ result = "previewed" if DRY_RUN else "pushed"
974
+ print(f"\n[batch] done, {len(groups)} group(s) {result}")
727
975
 
728
976
 
729
977
  def main():
730
- global FORCE
731
-
978
+ global PROBE_ROUTES, DRY_RUN
732
979
  if len(sys.argv) < 2:
733
980
  print(f"usage: draftgo_push.py {{{'|'.join(HANDLERS)}}} [id ...]\n"
734
- f" draftgo_push.py --batch <mode> <ids> [<mode> <ids> ...]\n"
735
- f" --force 跳过冲突检测,强制覆盖")
981
+ f" draftgo_push.py --batch <mode> <ids> [<mode> <ids> ...]")
736
982
  sys.exit(1)
737
983
 
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] 已跳过冲突检测,将强制覆盖云端数据")
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"]
743
991
 
744
992
  if not args:
745
993
  print(f"usage: draftgo_push.py {{{'|'.join(HANDLERS)}}} [id ...]")
746
994
  sys.exit(1)
747
995
 
996
+ if args[0] == "--all":
997
+ args = ["--batch", *HANDLERS]
998
+
748
999
  if args[0] == "--batch":
749
1000
  server, token, cfg = load_config()
750
1001
  run_batch(server, token, args[1:])
751
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)
752
1006
  return
753
1007
 
754
1008
  if args[0] not in HANDLERS:
@@ -759,6 +1013,9 @@ def main():
759
1013
  server, token, cfg = load_config()
760
1014
  HANDLERS[mode](server, token, ids)
761
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)
762
1019
 
763
1020
 
764
1021
  if __name__ == "__main__":