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
package/src/projectMap.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const parse5 = require('parse5');
5
6
 
6
7
  function exists(p) {
7
8
  try { fs.accessSync(p); return true; } catch { return false; }
@@ -60,20 +61,52 @@ function itemFile(projectDir, section, item) {
60
61
  function extractRoutes(html) {
61
62
  const routes = new Set();
62
63
  if (!html) return routes;
63
- const patterns = [
64
- /\bdata-page-route\s*=\s*["']([^"']+)["']/gi,
65
- /\bhref\s*=\s*["']([^"']+)["']/gi,
66
- ];
67
- for (const re of patterns) {
68
- let m;
69
- while ((m = re.exec(html))) {
70
- const route = normalizeRoute(m[1]);
71
- if (route) routes.add(route);
72
- }
64
+ let document;
65
+ try {
66
+ document = parse5.parse(html);
67
+ } catch {
68
+ return routes;
73
69
  }
70
+ const visit = (node) => {
71
+ for (const attr of node.attrs || []) {
72
+ if (attr.name === 'href' || attr.name === 'data-page-route') {
73
+ const route = normalizeRoute(attr.value);
74
+ if (route) routes.add(route);
75
+ }
76
+ if (attr.name.startsWith('on')) {
77
+ const re = /\b(?:App\.)?(?:navigate|go|openPage)\s*\(\s*["']([^"']+)["']/g;
78
+ let match;
79
+ while ((match = re.exec(attr.value))) {
80
+ const route = normalizeRoute(match[1]);
81
+ if (route) routes.add(route);
82
+ }
83
+ }
84
+ }
85
+ for (const child of node.childNodes || []) visit(child);
86
+ };
87
+ visit(document);
74
88
  return routes;
75
89
  }
76
90
 
91
+ function extractScriptBindings(code) {
92
+ const bindings = { routes: [], events: [], schedules: [] };
93
+ if (!code) return bindings;
94
+ let match;
95
+ const routeRe = /@route\(\s*["']([A-Za-z]+)\s+([^"']+)["']\s*\)/g;
96
+ while ((match = routeRe.exec(code))) bindings.routes.push(`${match[1].toUpperCase()} ${match[2]}`);
97
+ const eventRe = /@on\(\s*["']([^"']+)["']\s*\)/g;
98
+ while ((match = eventRe.exec(code))) bindings.events.push(match[1]);
99
+ const scheduledRe = /@scheduled\(\s*["']([^"']+)["']\s*\)/g;
100
+ while ((match = scheduledRe.exec(code))) bindings.schedules.push(match[1]);
101
+ const goRouteRe = /\.Route\(\s*["']([A-Za-z]+)["']\s*,\s*["']([^"']+)["']/g;
102
+ while ((match = goRouteRe.exec(code))) bindings.routes.push(`${match[1].toUpperCase()} ${match[2]}`);
103
+ const goEventRe = /\.On\(\s*["']([^"']+)["']/g;
104
+ while ((match = goEventRe.exec(code))) bindings.events.push(match[1]);
105
+ const goScheduleRe = /\.Schedule\(\s*["']([^"']+)["']/g;
106
+ while ((match = goScheduleRe.exec(code))) bindings.schedules.push(match[1]);
107
+ return bindings;
108
+ }
109
+
77
110
  function pageKey(page) {
78
111
  if (page && page.id != null) return `page:${page.id}`;
79
112
  return `page:${normalizeRoute(page && page.route) || page && page.title || 'new'}`;
@@ -86,6 +119,42 @@ function addRouteRefs(refs, route, source) {
86
119
  refs.get(clean).add(source);
87
120
  }
88
121
 
122
+ function classifyPageArea(item) {
123
+ const route = normalizeRoute(item && item.route);
124
+ const title = String((item && item.title) || '');
125
+ const tag = String((item && item.tag) || '');
126
+ const text = `${route} ${title} ${tag}`;
127
+
128
+ if (!route) return 'unknown';
129
+ if (route === '/' || /(^|[\s/])(home|index|首页|主页)([\s/]|$)/i.test(text)) return 'home';
130
+ if (/^\/(login|setup|install|register)(\/|$)/i.test(route) || /(系统|内置|平台配置|基础配置)/.test(text)) return 'system';
131
+ if (/^\/admin(\/|$)/i.test(route) || /(管理|后台|运营|审核|权限|控制台|配置)/.test(text)) return 'admin';
132
+ if (/(业务|内容|案例|产品|新闻|订单|客户|会员|课程|活动|资料|下载|预约|表单|详情|列表|列表页|工作台|中心|门户|商城|服务)/.test(text)) return 'business';
133
+ return 'unknown';
134
+ }
135
+
136
+ function normalizeHtmlSkeleton(html) {
137
+ if (!html) return '';
138
+ let out = html;
139
+ out = out.replace(/<!--([\s\S]*?)-->/g, '');
140
+ out = out.replace(/<script\b[\s\S]*?<\/script>/gi, '<script></script>');
141
+ out = out.replace(/<style\b[\s\S]*?<\/style>/gi, '<style></style>');
142
+ out = out.replace(/\b(data-page-route|href|id|for|aria-label|aria-labelledby|aria-describedby|title|value|name|placeholder)\s*=\s*("[^"]*"|'[^']*')/gi, '$1=""');
143
+ out = out.replace(/\b(class|style)\s*=\s*("[^"]*"|'[^']*')/gi, '$1=""');
144
+ out = out.replace(/>[^<]*</g, '><');
145
+ out = out.replace(/\s+/g, ' ');
146
+ return out.trim();
147
+ }
148
+
149
+ function collectPageGroups(pages) {
150
+ const groups = { home: [], admin: [], business: [], system: [], unknown: [] };
151
+ for (const page of pages) {
152
+ const key = groups[page.area] ? page.area : 'unknown';
153
+ groups[key].push(page);
154
+ }
155
+ return groups;
156
+ }
157
+
89
158
  function summarizePage(projectDir, item) {
90
159
  const file = itemFile(projectDir, 'pages', item);
91
160
  return {
@@ -95,6 +164,7 @@ function summarizePage(projectDir, item) {
95
164
  permission: item.permission || null,
96
165
  tag: item.tag || '',
97
166
  html_file: file,
167
+ area: classifyPageArea(item),
98
168
  };
99
169
  }
100
170
 
@@ -198,19 +268,6 @@ function summarizeAIHub(item) {
198
268
  return base;
199
269
  }
200
270
 
201
- function summarizeExternalAPI(item) {
202
- return {
203
- id: item.id,
204
- code: item.code || '',
205
- name: item.name || '',
206
- method: item.method || 'GET',
207
- path: item.path || '',
208
- base_url: item.base_url || '',
209
- status: item.status,
210
- tags: Array.isArray(item.tags) ? item.tags : [],
211
- };
212
- }
213
-
214
271
  function summarizeDoc(item) {
215
272
  return {
216
273
  id: item.id,
@@ -250,7 +307,6 @@ function buildProjectMap(projectDir) {
250
307
  db_meta: readJsonSafe(projectDir, '.draftgo/db_meta/index.json'),
251
308
  custom_scripts: readJsonSafe(projectDir, '.draftgo/custom_scripts/index.json'),
252
309
  aihub: readJsonSafe(projectDir, '.draftgo/aihub/index.json'),
253
- external_apis: readJsonSafe(projectDir, '.draftgo/external_apis/index.json'),
254
310
  docs: readJsonSafe(projectDir, '.draftgo/docs/articles/index.json'),
255
311
  doc_categories: readJsonSafe(projectDir, '.draftgo/doc_categories/index.json'),
256
312
  system_config: readJsonSafe(projectDir, '.draftgo/system_config/index.json'),
@@ -261,6 +317,7 @@ function buildProjectMap(projectDir) {
261
317
  const pages = indexes.pages.items.map((p) => summarizePage(projectDir, p));
262
318
  const navigations = indexes.navigations.items.map((n) => summarizeNav(projectDir, n));
263
319
  const routeRefs = new Map();
320
+ const pageGroups = collectPageGroups(pages);
264
321
 
265
322
  for (const nav of navigations) {
266
323
  const html = nav.html_file ? readTextSafe(relToAbs(projectDir, nav.html_file)) : '';
@@ -283,25 +340,135 @@ function buildProjectMap(projectDir) {
283
340
  label: m.label || '',
284
341
  fields: m.schema && m.schema.properties ? Object.keys(m.schema.properties) : [],
285
342
  })),
286
- custom_scripts: indexes.custom_scripts.items.map((s) => ({
287
- id: s.id,
288
- name: s.name || '',
289
- slug: s.slug || '',
290
- mode: s.mode || '',
291
- status: s.status,
292
- code_file: itemFile(projectDir, 'custom_scripts', s),
293
- })),
343
+ custom_scripts: indexes.custom_scripts.items.map((s) => {
344
+ const codeFile = itemFile(projectDir, 'custom_scripts', s);
345
+ const bindings = extractScriptBindings(codeFile ? readTextSafe(relToAbs(projectDir, codeFile)) : '');
346
+ return {
347
+ id: s.id,
348
+ name: s.name || '',
349
+ slug: s.slug || '',
350
+ mode: s.mode || '',
351
+ status: s.status,
352
+ code_file: codeFile,
353
+ routes: bindings.routes,
354
+ events: bindings.events,
355
+ schedules: bindings.schedules,
356
+ };
357
+ }),
294
358
  aihub: indexes.aihub.items.map(summarizeAIHub),
295
- external_apis: indexes.external_apis.items.map(summarizeExternalAPI),
296
359
  docs: indexes.docs.items.map(summarizeDoc),
297
360
  doc_categories: indexes.doc_categories.items.map(summarizeDocCategory),
298
361
  system_config: indexes.system_config.items.map(summarizeSystemConfig),
299
362
  roles: indexes.roles.items.map((r) => ({ id: r.id, code: r.code || '', name: r.name || '', status: r.status })),
300
363
  users_count: indexes.users.items.length,
364
+ pageGroups,
301
365
  routeRefs: Object.fromEntries([...routeRefs.entries()].map(([route, sources]) => [route, [...sources]])),
302
366
  };
303
367
  }
304
368
 
369
+ function htmlParseErrors(html) {
370
+ const errors = [];
371
+ const ignored = new Set(['missing-doctype']);
372
+ try {
373
+ parse5.parse(html, {
374
+ onParseError(error) {
375
+ if (ignored.has(error.code)) return;
376
+ errors.push({
377
+ code: error.code || 'html-parse-error',
378
+ line: error.startLine || null,
379
+ column: error.startCol || null,
380
+ });
381
+ },
382
+ });
383
+ } catch (error) {
384
+ errors.push({ code: error.message || 'html-parse-error', line: null, column: null });
385
+ }
386
+ return errors;
387
+ }
388
+
389
+ function extractStyleText(html) {
390
+ const parts = [];
391
+ if (!html) return '';
392
+
393
+ const styleAttr = /\bstyle\s*=\s*(["'])([\s\S]*?)\1/gi;
394
+ let m;
395
+ while ((m = styleAttr.exec(html))) parts.push(m[2]);
396
+
397
+ const styleBlock = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
398
+ while ((m = styleBlock.exec(html))) parts.push(m[1]);
399
+
400
+ return parts.join('\n');
401
+ }
402
+
403
+ function maskDgVarFunctions(css) {
404
+ let out = '';
405
+ for (let i = 0; i < css.length; i += 1) {
406
+ const rest = css.slice(i).toLowerCase();
407
+ const fnStart = rest.startsWith('var(') ? 'var(' : rest.startsWith('color-mix(') ? 'color-mix(' : null;
408
+ if (!fnStart) {
409
+ out += css[i];
410
+ continue;
411
+ }
412
+
413
+ let j = i + fnStart.length;
414
+ let depth = 1;
415
+ while (j < css.length && depth > 0) {
416
+ if (css[j] === '(') depth += 1;
417
+ else if (css[j] === ')') depth -= 1;
418
+ j += 1;
419
+ }
420
+
421
+ const fn = css.slice(i, j);
422
+ if (/^var\(\s*--dg-/i.test(fn) || /^color-mix\([\s\S]*var\(\s*--dg-/i.test(fn)) {
423
+ out += ' '.repeat(fn.length);
424
+ }
425
+ else out += fn;
426
+ i = j - 1;
427
+ }
428
+ return out;
429
+ }
430
+
431
+ function hasHardcodedColor(css) {
432
+ const masked = maskDgVarFunctions(css);
433
+ return /#[0-9a-f]{3,8}\b/i.test(masked)
434
+ || /\b(?:rgb|rgba|hsl|hsla|color-mix)\s*\(/i.test(masked);
435
+ }
436
+
437
+ function hasDarkThemeCoverage(html) {
438
+ return /\[data-theme\s*=\s*["']dark["']\]/i.test(html)
439
+ || /\[data-theme\s*~=\s*["']dark["']\]/i.test(html)
440
+ || /\bdata-theme\s*=\s*["']dark["']/i.test(html);
441
+ }
442
+
443
+ function reportColorThemeRisk(label, html, addWarning) {
444
+ const css = extractStyleText(html);
445
+ if (!css || !hasHardcodedColor(css)) return;
446
+ if (hasDarkThemeCoverage(html)) return;
447
+ addWarning('DG-COLOR-001', 'low', `${label} 发现硬编码配色且未看到深色主题覆盖;优先使用系统 var(--dg-*) token,自主配色需兼容浅色与深色。`);
448
+ }
449
+
450
+ function collectOrphanFiles(projectDir, relDir, indexRel, fileField, prefix, exts) {
451
+ const dir = path.join(projectDir, relDir);
452
+ if (!exists(dir)) return [];
453
+ const index = readJsonSafe(projectDir, indexRel);
454
+ const indexed = new Set(index.items.map((it) => String(it[fileField] || '').replace(/\\/g, '/')));
455
+ return fs.readdirSync(dir)
456
+ .filter((name) => name.startsWith(`${prefix}_`) && exts.includes(path.extname(name)))
457
+ .map((name) => path.join(relDir, name).replace(/\\/g, '/'))
458
+ .filter((rel) => !indexed.has(rel));
459
+ }
460
+
461
+ function reportPaginationHack(label, html, addWarning) {
462
+ if (/\bApp\.get\s*\(\s*['"]db\/[^'"]+['"]\s*,\s*\{[\s\S]{0,240}\bpage_size\s*:\s*9999\b/i.test(html)) {
463
+ addWarning('DG-DATA-001', 'high', `${label} 使用 page_size: 9999 拉取 DB 数据;DraftGo 规范是不传 page/page_size 即返回全量。`);
464
+ }
465
+ }
466
+
467
+ function needsAdminCompanion(page) {
468
+ const text = `${page.route || ''} ${page.title || ''} ${page.tag || ''}`;
469
+ return /(案例|产品|新闻|订单|客户|会员|课程|活动|资料|下载|预约|表单|内容|招聘|发布|审核|上下架|商城)/.test(text);
470
+ }
471
+
305
472
  function isProbablySystemPage(page) {
306
473
  const tag = String(page.tag || '');
307
474
  const title = String(page.title || '');
@@ -313,7 +480,13 @@ function analyzeProject(projectDir) {
313
480
  const map = buildProjectMap(projectDir);
314
481
  const errors = [];
315
482
  const warnings = [];
483
+ const warningDetails = [];
484
+ const addWarning = (code, confidence, message) => {
485
+ warnings.push(message);
486
+ warningDetails.push({ code, confidence, message });
487
+ };
316
488
  const routeSeen = new Map();
489
+ const signatureSeen = new Map();
317
490
 
318
491
  if (!exists(path.join(projectDir, '.draftgo'))) {
319
492
  errors.push('未找到 .draftgo/,请先运行 draftgo init 或 draftgo connect。');
@@ -322,7 +495,7 @@ function analyzeProject(projectDir) {
322
495
  for (const page of map.pages) {
323
496
  const label = `${page.title || '未命名页面'}${page.id != null ? `#${page.id}` : ''}`;
324
497
  if (!page.route) errors.push(`${label} 缺少 route。`);
325
- if (!page.title) warnings.push(`${label} 缺少 title。`);
498
+ if (!page.title) addWarning('DG-PAGE-001', 'high', `${label} 缺少 title。`);
326
499
  if (!page.html_file) errors.push(`${label} 找不到 html_file 或 page_${page.id}_*.html。`);
327
500
 
328
501
  if (page.route) {
@@ -337,7 +510,7 @@ function analyzeProject(projectDir) {
337
510
  const own = pageKey(page);
338
511
  const externalRefs = refs.filter((s) => s !== own);
339
512
  if (externalRefs.length === 0) {
340
- warnings.push(`${label} (${page.route}) 未在导航、首页或其他页面入口中发现绑定引用。`);
513
+ addWarning('DG-ROUTE-001', 'medium', `${label} (${page.route}) 未在导航、首页或其他页面入口中发现绑定引用。`);
341
514
  }
342
515
  }
343
516
  }
@@ -345,19 +518,80 @@ function analyzeProject(projectDir) {
345
518
  if (page.html_file) {
346
519
  const html = readTextSafe(relToAbs(projectDir, page.html_file));
347
520
  if (/\b(mockData|demoData|fakeData|sampleData|staticData)\b/i.test(html)) {
348
- warnings.push(`${label} 疑似包含 mock/demo/fake/staticData,确认是否为用户明确要求的静态/demo。`);
521
+ addWarning('DG-MOCK-001', 'medium', `${label} 疑似包含 mock/demo/fake/staticData,确认是否为用户明确要求的静态/demo。`);
349
522
  }
350
523
  if (/\b(onclick|addEventListener)\b[\s\S]{0,120}\b(toast|alert)\b/i.test(html) && !/\b(App\.(post|put|delete|get)|fetch\s*\()/i.test(html)) {
351
- warnings.push(`${label} 疑似只有反馈提示、缺少真实数据读写或 API 调用。`);
524
+ addWarning('DG-CLOSURE-001', 'medium', `${label} 疑似只有反馈提示、缺少真实数据读写或 API 调用。`);
525
+ }
526
+ reportPaginationHack(label, html, addWarning);
527
+ reportColorThemeRisk(label, html, addWarning);
528
+ const signature = normalizeHtmlSkeleton(html);
529
+ if (signature) {
530
+ if (!signatureSeen.has(signature)) signatureSeen.set(signature, []);
531
+ signatureSeen.get(signature).push({ label, area: page.area });
532
+ }
533
+ const parseErrors = htmlParseErrors(html);
534
+ for (const parseError of parseErrors.slice(0, 5)) {
535
+ const at = parseError.line ? `(行 ${parseError.line}${parseError.column ? `:${parseError.column}` : ''})` : '';
536
+ addWarning('DG-HTML-001', 'high', `${label} HTML 解析提醒:${parseError.code}${at}。`);
352
537
  }
353
538
  }
354
539
  }
355
540
 
541
+ const adminCount = map.pageGroups.admin.length;
542
+ const maintainableBusinessPages = map.pageGroups.business.filter(needsAdminCompanion);
543
+ if (maintainableBusinessPages.length > 0 && adminCount === 0) {
544
+ addWarning(
545
+ 'DG-ADMIN-001',
546
+ 'medium',
547
+ `检测到 ${maintainableBusinessPages.length} 个具有可维护内容信号的业务页面,但未发现管理端页面;若内容需要运营维护,建议补管理端页面或明确说明不需要。`,
548
+ );
549
+ }
550
+
551
+ for (const labels of signatureSeen.values()) {
552
+ const nonSystem = labels.filter((item) => item.area !== 'system');
553
+ if (nonSystem.length < 3) continue;
554
+ addWarning(
555
+ 'DG-DUPLICATE-001',
556
+ 'low',
557
+ `发现 ${nonSystem.length} 个结构几乎一致的页面,疑似工作台副本:${nonSystem.map((item) => item.label).join('、')}。建议复刻内置导航/侧栏后按业务差异拆分,不要整页复制。`,
558
+ );
559
+ break;
560
+ }
561
+
562
+ for (const nav of map.navigations) {
563
+ if (!nav.html_file) continue;
564
+ const navLabel = `导航${nav.name || nav.code || nav.id || ''}`;
565
+ const html = readTextSafe(relToAbs(projectDir, nav.html_file));
566
+ reportColorThemeRisk(navLabel, html, addWarning);
567
+ }
568
+
569
+ const orphanGroups = [
570
+ ['页面', '.draftgo/pages', '.draftgo/pages/index.json', 'html_file', 'page', ['.html']],
571
+ ['导航', '.draftgo/navigations', '.draftgo/navigations/index.json', 'html_file', 'nav', ['.html']],
572
+ ['文档', '.draftgo/docs/articles', '.draftgo/docs/articles/index.json', 'content_file', 'article', ['.html', '.md']],
573
+ ['自定义脚本', '.draftgo/custom_scripts', '.draftgo/custom_scripts/index.json', 'code_file', 'script', ['.py', '.js', '.ts', '.sh', '.go', '.txt']],
574
+ ];
575
+ for (const [label, relDir, indexRel, fileField, prefix, exts] of orphanGroups) {
576
+ const orphans = collectOrphanFiles(projectDir, relDir, indexRel, fileField, prefix, exts);
577
+ if (orphans.length) {
578
+ addWarning('DG-ORPHAN-001', 'high', `${label}目录存在 ${orphans.length} 个未登记到 index.json 的文件:${orphans.slice(0, 5).join('、')}。push 不会上传这些文件。`);
579
+ }
580
+ }
581
+
582
+ for (const script of map.custom_scripts) {
583
+ if (String(script.mode || '').toLowerCase() !== 'route' || !script.code_file) continue;
584
+ const code = readTextSafe(relToAbs(projectDir, script.code_file));
585
+ if (/\bdef\s+handle\s*\(/.test(code) && !/@route\s*\(/.test(code)) {
586
+ errors.push(`自定义脚本${script.name || script.slug || script.id}#${script.id} 是 route 模式但只有 handle(),缺少 @route("METHOD /path") 注册,HTTP 端点不会命中。`);
587
+ }
588
+ }
589
+
356
590
  if (map.pages.length === 0 && exists(path.join(projectDir, '.draftgo'))) {
357
- warnings.push('未发现 pages/index.json 页面缓存;开发前建议先拉取页面。');
591
+ addWarning('DG-CACHE-001', 'low', '未发现 pages/index.json 页面缓存;开发前建议先拉取页面。');
358
592
  }
359
593
 
360
- return { map, errors, warnings };
594
+ return { map, errors, warnings, warningDetails };
361
595
  }
362
596
 
363
597
  module.exports = {
@@ -365,4 +599,5 @@ module.exports = {
365
599
  analyzeProject,
366
600
  normalizeRoute,
367
601
  extractRoutes,
602
+ extractScriptBindings,
368
603
  };
package/src/skill.js CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
3
  // Per-platform skill renderer.
4
4
  //
5
- // For every AI-tool target (see src/platforms.js) we render a self-contained
6
- // copy of the skill body into that tool's own directory. No more shared
7
- // .draftgo/skill/ indirection each AI tool can read the real content
8
- // natively, which dramatically improves command compliance.
5
+ // For every AI-tool target (see src/platforms.js) we render the complete
6
+ // instruction body into that tool's own directory. The large OpenAPI snapshot
7
+ // is shared once under .draftgo/skill-shared to avoid redundant copies.
9
8
  //
10
9
  // Templating in .md files:
11
10
  // {{SKILL_DIR}} → platform's project-relative skill dir (forward slashes)
12
11
  // {{SKILL_SCRIPTS}} → {{SKILL_DIR}}/scripts
12
+ // {{SKILL_SHARED}} → .draftgo/skill-shared
13
13
 
14
14
  const path = require('path');
15
15
  const fs = require('fs');
@@ -21,6 +21,8 @@ const {
21
21
  } = require('./fsx');
22
22
 
23
23
  const SKILL_SOURCE_DIR = path.join(RESOURCES_DIR, 'skill');
24
+ const SHARED_RESOURCE_DIR = path.join('.draftgo', 'skill-shared');
25
+ const SHARED_FILES = new Set(['quickref/api.json']);
24
26
 
25
27
  function getPackageVersion() {
26
28
  try {
@@ -54,12 +56,32 @@ function renderFrontmatter(fm) {
54
56
  return lines.join('\n');
55
57
  }
56
58
 
59
+ function splitFrontmatter(text) {
60
+ const normalized = String(text || '').replace(/\r\n/g, '\n');
61
+ if (!normalized.startsWith('---\n')) return { frontmatter: {}, body: normalized };
62
+ const end = normalized.indexOf('\n---\n', 4);
63
+ if (end < 0) return { frontmatter: {}, body: normalized };
64
+ const frontmatter = {};
65
+ for (const line of normalized.slice(4, end).split('\n')) {
66
+ const colon = line.indexOf(':');
67
+ if (colon <= 0) continue;
68
+ const key = line.slice(0, colon).trim();
69
+ let value = line.slice(colon + 1).trim();
70
+ if (value.startsWith('"') && value.endsWith('"')) {
71
+ value = value.slice(1, -1).replace(/\\"/g, '"');
72
+ }
73
+ frontmatter[key] = value;
74
+ }
75
+ return { frontmatter, body: normalized.slice(end + 5) };
76
+ }
77
+
57
78
  function substitute(text, platform) {
58
79
  const skillDir = platform.skillDir;
59
80
  const scriptsDir = `${skillDir}/scripts`;
60
81
  return text
61
82
  .replace(/\{\{SKILL_DIR\}\}/g, skillDir)
62
- .replace(/\{\{SKILL_SCRIPTS\}\}/g, scriptsDir);
83
+ .replace(/\{\{SKILL_SCRIPTS\}\}/g, scriptsDir)
84
+ .replace(/\{\{SKILL_SHARED\}\}/g, SHARED_RESOURCE_DIR.replace(/\\/g, '/'));
63
85
  }
64
86
 
65
87
  function walk(root, onFile) {
@@ -76,27 +98,51 @@ function renderInto(destDir, platform) {
76
98
  const srcRoot = SKILL_SOURCE_DIR;
77
99
  walk(srcRoot, (absSrc) => {
78
100
  const rel = path.relative(srcRoot, absSrc);
101
+ if (SHARED_FILES.has(rel.replace(/\\/g, '/'))) return;
79
102
  const absDst = path.join(destDir, rel);
80
103
  ensureDir(path.dirname(absDst));
81
104
  if (absSrc.endsWith('.md')) {
82
105
  let body = readText(absSrc);
83
- body = substitute(body, platform);
84
106
  if (rel === 'SKILL.md') {
85
- body = renderFrontmatter(platform.frontmatter) + body;
107
+ const parsed = splitFrontmatter(body);
108
+ body = renderFrontmatter({ ...(platform.frontmatter || {}), ...parsed.frontmatter }) + parsed.body;
86
109
  }
110
+ body = substitute(body, platform);
87
111
  writeText(absDst, body);
88
112
  } else {
89
113
  copyFile(absSrc, absDst);
90
114
  }
91
115
  });
92
116
 
93
- // Some platforms expect the entry file at a different path/name than
94
- // SKILL.md (e.g. copilot's .github/prompts/draftgo.prompt.md, cursor's
95
- // .cursor/commands/draftgo.md). For those, mirror the rendered SKILL.md
96
- // to the platform's mainFile if it differs.
97
- const projectMain = path.join(path.dirname(destDir), '..'); // not used
98
- // We render into <project>/<assetDir>; mainFile lives at <project>/<mainFile>.
99
- // The renderer caller (installPlatform) handles cross-file copy.
117
+ }
118
+
119
+ function ensureSharedResources(projectDir) {
120
+ const sharedRoot = path.join(projectDir, SHARED_RESOURCE_DIR);
121
+ for (const rel of SHARED_FILES) {
122
+ const src = path.join(SKILL_SOURCE_DIR, rel);
123
+ const dest = path.join(sharedRoot, rel);
124
+ if (exists(dest)) {
125
+ const sourceStat = fs.statSync(src);
126
+ const destStat = fs.statSync(dest);
127
+ if (sourceStat.size === destStat.size && destStat.mtimeMs >= sourceStat.mtimeMs) continue;
128
+ }
129
+ copyFile(src, dest);
130
+ }
131
+ return sharedRoot;
132
+ }
133
+
134
+ function validateRenderedSkill(assetDir) {
135
+ const main = path.join(assetDir, 'SKILL.md');
136
+ if (!exists(main)) throw new Error('渲染结果缺少 SKILL.md');
137
+ const text = readText(main);
138
+ const firstEnd = text.indexOf('\n---\n', 4);
139
+ const body = firstEnd >= 0 ? text.slice(firstEnd + 5) : '';
140
+ if (!text.startsWith('---\n') || firstEnd < 0 || body.startsWith('---\n')) {
141
+ throw new Error('SKILL.md frontmatter 必须且只能有一个');
142
+ }
143
+ if (/\{\{(?:SKILL_DIR|SKILL_SCRIPTS|SKILL_SHARED)\}\}/.test(text)) {
144
+ throw new Error('SKILL.md 仍包含未替换占位符');
145
+ }
100
146
  }
101
147
 
102
148
  function ensureRuntime(projectDir) {
@@ -111,9 +157,10 @@ function ensureRuntime(projectDir) {
111
157
  }
112
158
  appendGitignoreLine(projectDir, '.draftgo/config.json');
113
159
  appendGitignoreLine(projectDir, '.draftgo/token');
160
+ ensureSharedResources(projectDir);
114
161
  }
115
162
 
116
- function installPlatform(projectDir, platform /*, { force } */) {
163
+ function installPlatform(projectDir, platform, opts = {}) {
117
164
  if (!exists(SKILL_SOURCE_DIR)) {
118
165
  throw new Error(
119
166
  `Skill source missing: ${SKILL_SOURCE_DIR}. The CLI package may be broken.`
@@ -121,24 +168,59 @@ function installPlatform(projectDir, platform /*, { force } */) {
121
168
  }
122
169
  const destAsset = path.join(projectDir, platform.assetDir);
123
170
  const destMain = path.join(projectDir, platform.mainFile);
124
-
125
- // Clean install so removed sub-skills/scripts don't linger.
126
- if (exists(destAsset)) removePath(destAsset);
127
- // If mainFile lives outside assetDir, clean it too.
128
- if (path.relative(destAsset, destMain).startsWith('..')) {
129
- if (exists(destMain)) removePath(destMain);
171
+ const mainOutsideAsset = path.relative(destAsset, destMain).startsWith('..');
172
+ if ((exists(destAsset) || exists(destMain)) && !opts.force) {
173
+ return { path: platform.mainFile, skipped: true };
130
174
  }
131
175
 
132
- renderInto(destAsset, platform);
176
+ ensureSharedResources(projectDir);
177
+ const token = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
178
+ const stageAsset = `${destAsset}.tmp-${token}`;
179
+ const backupAsset = `${destAsset}.bak-${token}`;
180
+ const stageMain = mainOutsideAsset ? `${destMain}.tmp-${token}` : null;
181
+ const backupMain = mainOutsideAsset ? `${destMain}.bak-${token}` : null;
182
+ let assetBackedUp = false;
183
+ let mainBackedUp = false;
184
+ let assetInstalled = false;
185
+ let mainInstalled = false;
186
+
187
+ try {
188
+ renderInto(stageAsset, platform);
189
+ validateRenderedSkill(stageAsset);
190
+ if (mainOutsideAsset) copyFile(path.join(stageAsset, 'SKILL.md'), stageMain);
133
191
 
134
- // If mainFile is not at <assetDir>/SKILL.md, mirror the rendered SKILL.md
135
- // to the platform's mainFile path (e.g. copilot prompt file, cursor cmd md).
136
- const renderedMain = path.join(destAsset, 'SKILL.md');
137
- if (path.resolve(renderedMain) !== path.resolve(destMain)) {
138
- ensureDir(path.dirname(destMain));
139
- copyFile(renderedMain, destMain);
192
+ ensureDir(path.dirname(destAsset));
193
+ if (exists(destAsset)) {
194
+ fs.renameSync(destAsset, backupAsset);
195
+ assetBackedUp = true;
196
+ }
197
+ if (mainOutsideAsset && exists(destMain)) {
198
+ ensureDir(path.dirname(destMain));
199
+ fs.renameSync(destMain, backupMain);
200
+ mainBackedUp = true;
201
+ }
202
+
203
+ fs.renameSync(stageAsset, destAsset);
204
+ assetInstalled = true;
205
+ if (mainOutsideAsset) {
206
+ fs.renameSync(stageMain, destMain);
207
+ mainInstalled = true;
208
+ }
209
+ } catch (err) {
210
+ if (mainInstalled && exists(destMain)) removePath(destMain);
211
+ if (assetInstalled && exists(destAsset)) removePath(destAsset);
212
+ if (mainBackedUp && exists(backupMain)) fs.renameSync(backupMain, destMain);
213
+ if (assetBackedUp && exists(backupAsset)) fs.renameSync(backupAsset, destAsset);
214
+ throw err;
215
+ } finally {
216
+ if (exists(stageAsset)) removePath(stageAsset);
217
+ if (stageMain && exists(stageMain)) removePath(stageMain);
140
218
  }
141
219
 
220
+ if (exists(backupAsset)) removePath(backupAsset);
221
+ if (backupMain && exists(backupMain)) removePath(backupMain);
222
+ if (!exists(destMain)) throw new Error(`安装后缺少入口文件:${platform.mainFile}`);
223
+
142
224
  return { path: platform.mainFile };
143
225
  }
144
226
 
@@ -163,7 +245,7 @@ function installAll(projectDir, platforms, opts = {}) {
163
245
  for (const p of platforms) {
164
246
  results.push({ platform: p, result: installPlatform(projectDir, p, opts) });
165
247
  }
166
- writeInstalledVersion(projectDir);
248
+ if (results.every((item) => !item.result.skipped)) writeInstalledVersion(projectDir);
167
249
  return results;
168
250
  }
169
251
 
@@ -177,4 +259,6 @@ module.exports = {
177
259
  statusPlatform,
178
260
  installAll,
179
261
  ensureRuntime,
262
+ ensureSharedResources,
263
+ splitFrontmatter,
180
264
  };