draftgo-cli 3.0.35 → 3.0.39

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 (64) hide show
  1. package/README.md +220 -272
  2. package/package.json +6 -2
  3. package/resources/skill/SKILL.md +114 -55
  4. package/resources/skill/init/SKILL.md +29 -15
  5. package/resources/skill/manifest.json +5 -4
  6. package/resources/skill/push/SKILL.md +41 -29
  7. package/resources/skill/references/aihub.md +8 -5
  8. package/resources/skill/references/api-endpoints.md +5 -3
  9. package/resources/skill/references/architecture.md +1 -1
  10. package/resources/skill/references/checkout.md +116 -0
  11. package/resources/skill/references/custom-services.md +9 -10
  12. package/resources/skill/references/data.md +4 -2
  13. package/resources/skill/references/frontend.md +1 -1
  14. package/resources/skill/references/mcp.md +101 -0
  15. package/resources/skill/references/modules.md +8 -8
  16. package/resources/skill/references/parallel.md +6 -3
  17. package/resources/skill/references/runtime.md +7 -10
  18. package/resources/skill/scripts/README.md +8 -0
  19. package/resources/skill/story/SKILL.md +8 -8
  20. package/src/cli.js +5 -0
  21. package/src/commandRegistry.js +7 -1
  22. package/src/commands/api.js +24 -187
  23. package/src/commands/autoPush.js +48 -17
  24. package/src/commands/check.js +17 -47
  25. package/src/commands/checkout.js +18 -0
  26. package/src/commands/commit.js +21 -0
  27. package/src/commands/conflict.js +30 -0
  28. package/src/commands/conflicts.js +16 -0
  29. package/src/commands/connect.js +60 -48
  30. package/src/commands/delete.js +79 -64
  31. package/src/commands/deploy.js +18 -10
  32. package/src/commands/diff.js +23 -0
  33. package/src/commands/help.js +99 -75
  34. package/src/commands/init.js +4 -10
  35. package/src/commands/local.js +23 -6
  36. package/src/commands/map.js +89 -89
  37. package/src/commands/mcp.js +126 -0
  38. package/src/commands/sync.js +28 -43
  39. package/src/commands/verifyUi.js +3 -2
  40. package/src/localdev/index.js +37 -7
  41. package/src/localdev/mysqlClient.js +1 -1
  42. package/src/mcp/client.js +275 -0
  43. package/src/mcp/hosts.js +520 -0
  44. package/src/mcp/protocol.js +173 -0
  45. package/src/mcp/stdio.js +300 -0
  46. package/src/mcp/tools.js +47 -0
  47. package/src/platforms.js +3 -4
  48. package/src/projectConfig.js +91 -49
  49. package/src/projectMap.js +123 -460
  50. package/src/skill.js +6 -28
  51. package/src/worktree/backend.js +326 -0
  52. package/src/worktree/errors.js +28 -0
  53. package/src/worktree/index.js +461 -0
  54. package/src/worktree/manifest.js +75 -0
  55. package/src/worktree/streams.js +200 -0
  56. package/src/worktree/types.js +103 -0
  57. package/src/worktree/validate.js +37 -0
  58. package/resources/skill/pull/SKILL.md +0 -33
  59. package/resources/skill/references/api.json +0 -20248
  60. package/resources/skill/scripts/draftgo_delete.py +0 -149
  61. package/resources/skill/scripts/draftgo_init.py +0 -80
  62. package/resources/skill/scripts/draftgo_pull.py +0 -427
  63. package/resources/skill/scripts/draftgo_push.py +0 -1022
  64. package/src/python.js +0 -27
@@ -1,149 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- DraftGo Delete Script
4
- 删除云端资源,并同步移除本地 index.json 条目和文件。
5
-
6
- 用法:
7
- python draftgo_delete.py pages <id>
8
- python draftgo_delete.py nav <id>
9
- python draftgo_delete.py db_meta <id>
10
- python draftgo_delete.py custom_scripts <id>
11
- python draftgo_delete.py docs <id>
12
- python draftgo_delete.py doc_categories <id>
13
- python draftgo_delete.py aihub <id>
14
- """
15
- import json, sys
16
- from pathlib import Path
17
- import urllib.request, urllib.error
18
-
19
- SCRIPT_DIR = Path(__file__).resolve().parent
20
-
21
-
22
- def find_project_root(start: Path) -> Path:
23
- cur = start
24
- for _ in range(10):
25
- if (cur / ".draftgo").is_dir():
26
- return cur
27
- if cur.parent == cur:
28
- break
29
- cur = cur.parent
30
- cwd = Path.cwd()
31
- if (cwd / ".draftgo").is_dir():
32
- return cwd
33
- return start.parents[3] if len(start.parents) >= 4 else start
34
-
35
-
36
- DEFAULT_ROOT = find_project_root(SCRIPT_DIR)
37
-
38
-
39
- def load_config():
40
- cfg_path = DEFAULT_ROOT / ".draftgo/config.json"
41
- if not cfg_path.exists():
42
- print("ERR: .draftgo/config.json not found, run /draftgo init first", file=sys.stderr)
43
- sys.exit(1)
44
- cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
45
- return cfg["server"].rstrip("/"), cfg["token"]
46
-
47
-
48
- def api_delete(server, token, path):
49
- headers = {
50
- "Authorization": f"Bearer {token}",
51
- "Accept": "application/json",
52
- }
53
- req = urllib.request.Request(
54
- f"{server}{path}",
55
- method="DELETE",
56
- headers=headers,
57
- )
58
- try:
59
- with urllib.request.urlopen(req, timeout=15) as r:
60
- return True, r.status, r.read().decode("utf-8", errors="replace")
61
- except urllib.error.HTTPError as e:
62
- return False, e.code, e.read().decode(errors="replace")
63
- except Exception as e:
64
- return False, None, str(e)
65
-
66
-
67
- def _load_index(rel_index):
68
- new_path = DEFAULT_ROOT / ".draftgo" / rel_index
69
- legacy_path = DEFAULT_ROOT / rel_index
70
- path = new_path if new_path.exists() else legacy_path
71
- if not path.exists():
72
- print(f"ERR: {rel_index} not found", file=sys.stderr)
73
- sys.exit(1)
74
- return path, json.loads(path.read_text(encoding="utf-8"))
75
-
76
-
77
- def _remove_local_file(rel_path):
78
- if not rel_path:
79
- return
80
- full = DEFAULT_ROOT / rel_path
81
- if full.exists():
82
- full.unlink()
83
- print(f" deleted local file: {rel_path}")
84
-
85
-
86
- # type → (api_prefix, index_rel, id_field, file_field)
87
- TYPE_MAP = {
88
- "pages": ("/api/pages", "pages/index.json", "id", "html_file"),
89
- "nav": ("/api/navigations", "navigations/index.json", "id", "html_file"),
90
- "db_meta": ("/api/db-meta", "db_meta/index.json", "id", None),
91
- "custom_scripts": ("/api/scripts", "custom_scripts/index.json", "id", "code_file"),
92
- "docs": ("/api/docs/articles", "docs/articles/index.json", "id", "content_file"),
93
- "doc_categories": ("/api/docs/categories", "doc_categories/index.json", "id", None),
94
- "aihub": ("/api/aihub", "aihub/index.json", "id", None),
95
- }
96
-
97
-
98
- def delete_resource(type_name, resource_id):
99
- if type_name not in TYPE_MAP:
100
- print(f"ERR: unknown type '{type_name}'. Supported: {', '.join(TYPE_MAP)}", file=sys.stderr)
101
- sys.exit(1)
102
-
103
- api_prefix, index_rel, id_field, file_field = TYPE_MAP[type_name]
104
- server, token = load_config()
105
-
106
- # 1. 找到 index 条目
107
- index_path, items = _load_index(index_rel)
108
- item = next((it for it in items if str(it.get(id_field)) == str(resource_id)), None)
109
- label = (
110
- item.get("title") or item.get("name") or item.get("type") or item.get("slug") or resource_id
111
- if item else resource_id
112
- )
113
-
114
- # 2. 调用 DELETE API
115
- ok, status, body = api_delete(server, token, f"{api_prefix}/{resource_id}")
116
- if not ok and status != 404:
117
- print(f" ERR [{label}] 删除失败: HTTP {status}: {body}")
118
- sys.exit(1)
119
-
120
- if status == 404:
121
- print(f" WARN [{label}] 云端不存在(id={resource_id}),仅清理本地")
122
- else:
123
- print(f" OK [{label}] 已从云端删除(id={resource_id})")
124
-
125
- # 3. 删除本地文件
126
- if item and file_field:
127
- _remove_local_file(item.get(file_field))
128
-
129
- # 4. 从 index 移除条目
130
- new_items = [it for it in items if str(it.get(id_field)) != str(resource_id)]
131
- if len(new_items) < len(items):
132
- index_path.write_text(json.dumps(new_items, ensure_ascii=False, indent=2), encoding="utf-8")
133
- print(f" OK [{label}] 已从本地 index 移除")
134
- else:
135
- print(f" WARN [{label}] 本地 index 中未找到 id={resource_id}")
136
-
137
-
138
- def main():
139
- if len(sys.argv) < 3:
140
- print(f"usage: draftgo_delete.py <{'|'.join(TYPE_MAP)}> <id>", file=sys.stderr)
141
- sys.exit(1)
142
-
143
- type_name = sys.argv[1]
144
- resource_id = sys.argv[2]
145
- delete_resource(type_name, resource_id)
146
-
147
-
148
- if __name__ == "__main__":
149
- main()
@@ -1,80 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- DraftGo Init Script
4
- 初始化项目配置,并从云端拉取全量数据到本地。
5
-
6
- 用法:
7
- DRAFTGO_TOKEN=yyy python draftgo_init.py --server https://xxx [project_dir]
8
- """
9
- import argparse, json, os, sys
10
- from pathlib import Path
11
-
12
- SCRIPT_DIR = Path(__file__).resolve().parent
13
-
14
- # 同目录下的 pull 模块
15
- sys.path.insert(0, str(SCRIPT_DIR))
16
- from draftgo_pull import find_project_root, pull_all, load_config # noqa: E402
17
-
18
- DEFAULT_ROOT = find_project_root(SCRIPT_DIR)
19
-
20
-
21
- def main():
22
- parser = argparse.ArgumentParser()
23
- parser.add_argument("--server", required=True)
24
- parser.add_argument("--token", required=False, help=argparse.SUPPRESS)
25
- parser.add_argument(
26
- "project_dir",
27
- nargs="?",
28
- default=None,
29
- help="项目根目录(可选),默认自动推导",
30
- )
31
- args = parser.parse_args()
32
-
33
- server = args.server.rstrip("/")
34
- token = args.token or os.environ.get("DRAFTGO_TOKEN")
35
- if not token:
36
- parser.error("token is required: set DRAFTGO_TOKEN or pass --token")
37
- root = Path(args.project_dir).resolve() if args.project_dir else DEFAULT_ROOT
38
- dg_dir = root / ".draftgo"
39
- dg_dir.mkdir(parents=True, exist_ok=True)
40
-
41
- # 写入配置。重复初始化时保留用户明确启用的自动推送开关。
42
- config_path = dg_dir / "config.json"
43
- old_cfg = {}
44
- if config_path.exists():
45
- try:
46
- old_cfg = json.loads(config_path.read_text(encoding="utf-8"))
47
- except Exception:
48
- pass
49
- cfg = {
50
- "server": server,
51
- "token": token,
52
- "lessons_on_push": old_cfg.get("lessons_on_push", True),
53
- "auto_push": old_cfg.get("auto_push", False) is True,
54
- }
55
- config_path.write_text(
56
- json.dumps(cfg, indent=2),
57
- encoding="utf-8",
58
- )
59
-
60
- # .gitignore 保护敏感配置
61
- gi = root / ".gitignore"
62
- content = gi.read_text(encoding="utf-8") if gi.exists() else ""
63
- if ".draftgo/config.json" not in content:
64
- with gi.open("a", encoding="utf-8") as f:
65
- f.write("\n.draftgo/config.json\n")
66
-
67
- # 拉取全量数据
68
- print("Pulling all resources from cloud...")
69
- pull_all(server, token, root)
70
-
71
- # 创建辅助目录
72
- for d in ("lessons", "Task"):
73
- (dg_dir / d).mkdir(parents=True, exist_ok=True)
74
- print(" OK .draftgo/lessons/ .draftgo/Task/")
75
-
76
- print(f"\nDone. server: {server}")
77
-
78
-
79
- if __name__ == "__main__":
80
- main()
@@ -1,427 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- DraftGo Pull Script
4
- 从云端拉取数据到本地 .draftgo/ 目录,支持按类型增量拉取。
5
-
6
- 用法:
7
- python draftgo_pull.py pages [page_id ...]
8
- python draftgo_pull.py nav [nav_id ...]
9
- python draftgo_pull.py db_meta [db_meta_id ...]
10
- python draftgo_pull.py aihub [aihub_id ...]
11
- python draftgo_pull.py system_config [config_key ...]
12
- python draftgo_pull.py docs [article_id ...]
13
- python draftgo_pull.py doc_categories [category_id ...]
14
- python draftgo_pull.py custom_scripts [script_id ...]
15
- python draftgo_pull.py roles [role_id ...]
16
- python draftgo_pull.py users [user_id ...]
17
- python draftgo_pull.py --all
18
- """
19
- import json, sys
20
- from pathlib import Path
21
- import urllib.request, urllib.error, urllib.parse
22
-
23
- SCRIPT_DIR = Path(__file__).resolve().parent
24
-
25
-
26
- def find_project_root(start: Path) -> Path:
27
- cur = start
28
- for _ in range(10):
29
- if (cur / ".draftgo").is_dir():
30
- return cur
31
- if cur.parent == cur:
32
- break
33
- cur = cur.parent
34
- # CWD 回退:脚本可能在中文路径下被调用,SCRIPT_DIR 解析失败时用 CWD
35
- cwd = Path.cwd()
36
- if (cwd / ".draftgo").is_dir():
37
- return cwd
38
- return start.parents[3] if len(start.parents) >= 4 else start
39
-
40
-
41
- DEFAULT_ROOT = find_project_root(SCRIPT_DIR)
42
-
43
- # ── 端点映射 ──
44
- ENDPOINTS = {
45
- "pages": "/api/pages/",
46
- "navigations": "/api/navigations",
47
- "roles": "/api/roles",
48
- "users": "/api/users",
49
- "db_meta": "/api/db-meta",
50
- "aihub": "/api/aihub",
51
- "system_config": "/api/system/config",
52
- "doc_categories": "/api/docs/categories?flat=true",
53
- "docs": "/api/docs/admin/articles",
54
- "custom_scripts": "/api/scripts/",
55
- }
56
-
57
- # ── 通用工具函数 ──
58
-
59
- def load_config(root=None):
60
- root = root or DEFAULT_ROOT
61
- cfg_path = root / ".draftgo/config.json"
62
- if not cfg_path.exists():
63
- print("ERR: .draftgo/config.json not found, run /draftgo init first", file=sys.stderr)
64
- sys.exit(1)
65
- cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
66
- return cfg["server"].rstrip("/"), cfg["token"]
67
-
68
-
69
- _UA = (
70
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
71
- "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
72
- )
73
-
74
-
75
- def fetch(server, token, path):
76
- url = f"{server}{path}"
77
- req = urllib.request.Request(
78
- url,
79
- headers={
80
- "Authorization": f"Bearer {token}",
81
- "User-Agent": _UA,
82
- "Accept": "application/json",
83
- },
84
- )
85
- try:
86
- with urllib.request.urlopen(req, timeout=15) as r:
87
- return json.loads(r.read())
88
- except urllib.error.HTTPError as e:
89
- print(f" ✗ {path} → HTTP {e.code}", file=sys.stderr)
90
- return None
91
- except Exception as e:
92
- print(f" ✗ {path} → {e}", file=sys.stderr)
93
- return None
94
-
95
-
96
- def unwrap(raw):
97
- """Unwrap unified envelope: {code, data, message} → data."""
98
- if isinstance(raw, dict) and "code" in raw and "data" in raw:
99
- return raw["data"]
100
- return raw
101
-
102
-
103
- def extract_items(raw):
104
- raw = unwrap(raw)
105
- if isinstance(raw, list):
106
- return raw
107
- if isinstance(raw, dict):
108
- for key in ("items", "results"):
109
- v = raw.get(key)
110
- if isinstance(v, list):
111
- return v
112
- if isinstance(raw.get("data"), list):
113
- return raw["data"]
114
- return []
115
-
116
-
117
- def safe_slug(s):
118
- return str(s).strip("/").replace("/", "_") or "root"
119
-
120
-
121
- def write_index(folder, items, preserve_existing=False, id_key="id"):
122
- if preserve_existing:
123
- index_path = folder / "index.json"
124
- try:
125
- existing = json.loads(index_path.read_text(encoding="utf-8"))
126
- if not isinstance(existing, list):
127
- existing = []
128
- except (OSError, json.JSONDecodeError):
129
- existing = []
130
- replacements = {
131
- str(item.get(id_key)): item for item in items if item.get(id_key) is not None
132
- }
133
- retained = []
134
- seen = set()
135
- for item in existing:
136
- key = str(item.get(id_key)) if item.get(id_key) is not None else None
137
- if key in replacements:
138
- retained.append(replacements[key])
139
- seen.add(key)
140
- else:
141
- retained.append(item)
142
- items = retained + [
143
- item for item in items
144
- if item.get(id_key) is None or str(item.get(id_key)) not in seen
145
- ]
146
- folder.mkdir(parents=True, exist_ok=True)
147
- (folder / "index.json").write_text(
148
- json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8"
149
- )
150
-
151
-
152
- # ── 各类型的 explode 逻辑 ──
153
-
154
- def explode_pages(out_dir, items, preserve_existing=False):
155
- index = []
156
- for p in items:
157
- pid = p.get("id", "x")
158
- fname = f"page_{pid}_{safe_slug(p.get('route', pid))}.html"
159
- html = (p.get("value") or {}).get("html", "")
160
- (out_dir / fname).write_text(html, encoding="utf-8")
161
- meta = {k: v for k, v in p.items() if k != "value"}
162
- meta["html_file"] = f".draftgo/pages/{fname}"
163
- index.append(meta)
164
- write_index(out_dir, index, preserve_existing)
165
- return len(index)
166
-
167
-
168
- def explode_navigations(out_dir, items, preserve_existing=False):
169
- index = []
170
- for item in items:
171
- iid = item.get("id", "x")
172
- name_slug = safe_slug(item.get("code") or item.get("route") or iid)
173
- fname = f"nav_{iid}_{name_slug}.html"
174
- # 列表接口只返回导航元数据。详情拉取失败时不能以空内容覆盖本地导航。
175
- if isinstance(item.get("html"), str):
176
- (out_dir / fname).write_text(item["html"], encoding="utf-8")
177
- else:
178
- print(
179
- f" WARN nav_id={iid} 未取得 HTML,保留本地文件: {fname}",
180
- file=sys.stderr,
181
- )
182
- meta = {k: v for k, v in item.items() if k != "html"}
183
- meta["html_file"] = f".draftgo/navigations/{fname}"
184
- index.append(meta)
185
- write_index(out_dir, index, preserve_existing)
186
- return len(index)
187
-
188
-
189
- def explode_docs(out_dir, items, preserve_existing=False):
190
- index = []
191
- for art in items:
192
- aid = art.get("id", "x")
193
- slug = safe_slug(art.get("slug") or art.get("title") or aid)
194
- fname = f"article_{aid}_{slug}.html"
195
- content = art.get("content") or ""
196
- (out_dir / fname).write_text(content, encoding="utf-8")
197
- meta = {k: v for k, v in art.items() if k != "content"}
198
- meta["content_file"] = f".draftgo/docs/articles/{fname}"
199
- index.append(meta)
200
- write_index(out_dir, index, preserve_existing)
201
- return len(index)
202
-
203
-
204
- def explode_scripts(out_dir, items, preserve_existing=False):
205
- index = []
206
- for sc in items:
207
- sid = sc.get("id", "x")
208
- slug = safe_slug(sc.get("slug") or sc.get("name") or sid)
209
- fname = f"script_{sid}_{slug}.go"
210
- # 列表接口不含 code;详情拉取失败时绝不能用空内容覆盖受管源码。
211
- if "code" in sc:
212
- (out_dir / fname).write_text(sc["code"], encoding="utf-8")
213
- else:
214
- print(f" WARN script_id={sid} 未取得代码,保留本地文件: {fname}", file=sys.stderr)
215
- meta = {k: v for k, v in sc.items() if k not in ("code", "language")}
216
- meta["code_file"] = f".draftgo/custom_scripts/{fname}"
217
- index.append(meta)
218
- write_index(out_dir, index, preserve_existing)
219
- return len(index)
220
-
221
-
222
- # ── 按类型拉取 ──
223
-
224
- def select_items(items, ids, id_key, type_name):
225
- """筛选指定目标,并拒绝用空结果覆盖现有索引。"""
226
- if not ids:
227
- return items
228
- wanted = [str(value) for value in ids]
229
- found = {str(item.get(id_key)) for item in items if item.get(id_key) is not None}
230
- missing = [value for value in wanted if value not in found]
231
- if missing:
232
- print(
233
- f" ERR {type_name} 云端未返回目标 {id_key}: {', '.join(missing)}",
234
- file=sys.stderr,
235
- )
236
- return None
237
- return [item for item in items if str(item.get(id_key)) in wanted]
238
-
239
- def pull_pages(server, token, root, ids=None):
240
- raw = fetch(server, token, ENDPOINTS["pages"])
241
- if raw is None:
242
- return None
243
- items = extract_items(raw)
244
- items = select_items(items, ids, "id", "pages")
245
- if items is None:
246
- return None
247
- if any(not isinstance(p.get("value"), dict) or not isinstance(p["value"].get("html"), str) for p in items):
248
- print(" ERR pages 响应缺少 HTML,未写入本地缓存", file=sys.stderr)
249
- return None
250
- out = root / ".draftgo/pages"
251
- out.mkdir(parents=True, exist_ok=True)
252
- count = explode_pages(out, items, preserve_existing=bool(ids))
253
- print(f" OK pages ({count} items)")
254
- return count
255
-
256
-
257
- def pull_navigations(server, token, root, ids=None):
258
- raw = fetch(server, token, ENDPOINTS["navigations"])
259
- if raw is None:
260
- return None
261
- items = extract_items(raw)
262
- items = select_items(items, ids, "id", "navigations")
263
- if items is None:
264
- return None
265
- hydrated = []
266
- for item in items:
267
- code = item.get("code")
268
- if not code:
269
- print(
270
- f" ERR nav_id={item.get('id', '?')} 缺少 code,未写入本地缓存",
271
- file=sys.stderr,
272
- )
273
- return None
274
- detail = unwrap(fetch(
275
- server, token, f"/api/navigations/{urllib.parse.quote(str(code), safe='')}"
276
- ))
277
- if not isinstance(detail, dict) or not isinstance(detail.get("html"), str):
278
- print(
279
- f" ERR nav_id={item.get('id', '?')} 未取得 HTML,未写入本地缓存",
280
- file=sys.stderr,
281
- )
282
- return None
283
- hydrated.append({**item, **detail})
284
- out = root / ".draftgo/navigations"
285
- out.mkdir(parents=True, exist_ok=True)
286
- count = explode_navigations(out, hydrated, preserve_existing=bool(ids))
287
- print(f" OK navigations ({count} items)")
288
- return count
289
-
290
-
291
- def pull_simple(server, token, root, type_name, ids=None):
292
- """拉取仅需 index.json 的简单类型(db_meta, aihub, roles, users, system_config)。"""
293
- raw = fetch(server, token, ENDPOINTS[type_name])
294
- if raw is None:
295
- return None
296
- items = extract_items(raw)
297
- id_key = "config_key" if type_name == "system_config" else "id"
298
- items = select_items(items, ids, id_key, type_name)
299
- if items is None:
300
- return None
301
- out = root / f".draftgo/{type_name}"
302
- write_index(out, items, preserve_existing=bool(ids), id_key=id_key)
303
- print(f" OK {type_name} ({len(items)} items)")
304
- return len(items)
305
-
306
-
307
- def pull_docs(server, token, root, ids=None):
308
- raw = fetch(server, token, ENDPOINTS["docs"])
309
- if raw is None:
310
- return None
311
- items = extract_items(raw)
312
- items = select_items(items, ids, "id", "docs")
313
- if items is None:
314
- return None
315
- hydrated = []
316
- for it in items:
317
- aid = it.get("id")
318
- detail = unwrap(fetch(server, token, f"/api/docs/articles/{aid}")) if aid else None
319
- if not isinstance(detail, dict) or not isinstance(detail.get("content"), str):
320
- print(
321
- f" ERR article_id={aid or '?'} 未取得正文,未写入本地缓存",
322
- file=sys.stderr,
323
- )
324
- return None
325
- hydrated.append({**it, **detail})
326
- out = root / ".draftgo/docs/articles"
327
- out.mkdir(parents=True, exist_ok=True)
328
- count = explode_docs(out, hydrated, preserve_existing=bool(ids))
329
- print(f" OK docs ({count} articles)")
330
- return count
331
-
332
-
333
- def pull_doc_categories(server, token, root, ids=None):
334
- raw = fetch(server, token, ENDPOINTS["doc_categories"])
335
- if raw is None:
336
- return None
337
- items = extract_items(raw)
338
- items = select_items(items, ids, "id", "doc_categories")
339
- if items is None:
340
- return None
341
- out = root / ".draftgo/doc_categories"
342
- write_index(out, items, preserve_existing=bool(ids))
343
- print(f" OK doc_categories ({len(items)} items)")
344
- return len(items)
345
-
346
-
347
- def pull_custom_scripts(server, token, root, ids=None):
348
- raw = fetch(server, token, ENDPOINTS["custom_scripts"])
349
- if raw is None:
350
- return None
351
- items = extract_items(raw)
352
- items = select_items(items, ids, "id", "custom_scripts")
353
- if items is None:
354
- return None
355
- hydrated = []
356
- for it in items:
357
- script_id = it.get("id")
358
- detail = unwrap(fetch(server, token, f"/api/scripts/{script_id}")) if script_id else None
359
- if not isinstance(detail, dict) or not isinstance(detail.get("code"), str):
360
- print(
361
- f" ERR script_id={script_id or '?'} 未取得代码,未写入本地缓存",
362
- file=sys.stderr,
363
- )
364
- return None
365
- hydrated.append({**it, **detail})
366
- out = root / ".draftgo/custom_scripts"
367
- out.mkdir(parents=True, exist_ok=True)
368
- count = explode_scripts(out, hydrated, preserve_existing=bool(ids))
369
- print(f" OK custom_scripts ({count} scripts, code from details)")
370
- return count
371
-
372
-
373
- # ── Handler 映射 ──
374
-
375
- PULL_HANDLERS = {
376
- "pages": pull_pages,
377
- "nav": lambda s, t, r, ids=None: pull_navigations(s, t, r, ids),
378
- "navigations": pull_navigations,
379
- "db_meta": lambda s, t, r, ids=None: pull_simple(s, t, r, "db_meta", ids),
380
- "aihub": lambda s, t, r, ids=None: pull_simple(s, t, r, "aihub", ids),
381
- "roles": lambda s, t, r, ids=None: pull_simple(s, t, r, "roles", ids),
382
- "users": lambda s, t, r, ids=None: pull_simple(s, t, r, "users", ids),
383
- "system_config": lambda s, t, r, ids=None: pull_simple(s, t, r, "system_config", ids),
384
- "docs": pull_docs,
385
- "doc_categories": pull_doc_categories,
386
- "custom_scripts": pull_custom_scripts,
387
- }
388
-
389
-
390
- def pull_all(server, token, root):
391
- """拉取全部类型,供 init 复用。"""
392
- results = [pull_pages(server, token, root), pull_navigations(server, token, root)]
393
- for t in ("db_meta", "aihub", "roles", "users", "system_config"):
394
- results.append(pull_simple(server, token, root, t))
395
- results.extend([
396
- pull_doc_categories(server, token, root),
397
- pull_docs(server, token, root),
398
- pull_custom_scripts(server, token, root),
399
- ])
400
- return None if any(result is None for result in results) else results
401
-
402
-
403
- def main():
404
- if len(sys.argv) < 2:
405
- print(f"usage: draftgo_pull.py {{{'|'.join(PULL_HANDLERS)}}}|--all [id ...]")
406
- sys.exit(1)
407
-
408
- mode = sys.argv[1]
409
- server, token = load_config()
410
- root = DEFAULT_ROOT
411
-
412
- if mode == "--all":
413
- if pull_all(server, token, root) is None:
414
- sys.exit(1)
415
- return
416
-
417
- if mode not in PULL_HANDLERS:
418
- print(f"unknown type: {mode}\navailable: {', '.join(PULL_HANDLERS)} or --all")
419
- sys.exit(1)
420
-
421
- ids = sys.argv[2:] or None
422
- if PULL_HANDLERS[mode](server, token, root, ids) is None:
423
- sys.exit(1)
424
-
425
-
426
- if __name__ == "__main__":
427
- main()