draftgo-cli 3.0.39 → 3.0.41

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.
@@ -0,0 +1,368 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const pkg = require('../../package.json');
7
+ const { RESOURCES_DIR } = require('../paths');
8
+ const { loadProjectConfig } = require('../projectConfig');
9
+ const { TOOL_NAMES, openToolSession } = require('../mcp/tools');
10
+ const { readInstalledVersion } = require('../skill');
11
+
12
+ const SKILL_ROOT = path.join(RESOURCES_DIR, 'skill');
13
+ const MAX_STRUCTURED_TEXT_BYTES = 1024 * 1024;
14
+ const ROOT_SECTIONS = [
15
+ '核心边界',
16
+ '强制预读:Reference 优先于 MCP',
17
+ '并行开发与所有权',
18
+ '标准工作流',
19
+ '安全规则',
20
+ '验证与交付',
21
+ ];
22
+
23
+ const TASK_PROFILES = Object.freeze({
24
+ frontend: {
25
+ apiQuery: 'frontend pages navigation',
26
+ references: [
27
+ { file: 'SKILL.md', headings: ROOT_SECTIONS },
28
+ { file: 'references/architecture.md' },
29
+ { file: 'references/modules.md' },
30
+ { file: 'references/frontend.md' },
31
+ { file: 'references/runtime.md' },
32
+ { file: 'references/app-api.md' },
33
+ { file: 'references/security.md' },
34
+ { file: 'references/checkout.md' },
35
+ { file: 'references/parallel.md' },
36
+ ],
37
+ },
38
+ data: {
39
+ apiQuery: 'db_meta schema data',
40
+ references: [
41
+ { file: 'SKILL.md', headings: ROOT_SECTIONS },
42
+ { file: 'references/modules.md' },
43
+ { file: 'references/data.md' },
44
+ { file: 'references/db-relations.md' },
45
+ { file: 'references/security.md' },
46
+ { file: 'references/mcp.md', headings: ['边界', '协议与安全'] },
47
+ { file: 'references/parallel.md' },
48
+ ],
49
+ },
50
+ 'custom-service': {
51
+ apiQuery: 'custom services routes permissions',
52
+ references: [
53
+ { file: 'SKILL.md', headings: ROOT_SECTIONS },
54
+ { file: 'references/modules.md' },
55
+ { file: 'references/custom-services.md' },
56
+ { file: 'references/data.md' },
57
+ { file: 'references/security.md' },
58
+ { file: 'references/parallel.md' },
59
+ ],
60
+ },
61
+ aihub: {
62
+ apiQuery: 'AIHub agents chat',
63
+ references: [
64
+ { file: 'SKILL.md', headings: ROOT_SECTIONS },
65
+ { file: 'references/aihub.md' },
66
+ { file: 'references/chat-sdk.md' },
67
+ { file: 'references/security.md' },
68
+ { file: 'references/parallel.md' },
69
+ ],
70
+ },
71
+ content: {
72
+ apiQuery: 'pages navigations docs',
73
+ references: [
74
+ { file: 'SKILL.md', headings: ROOT_SECTIONS },
75
+ { file: 'references/architecture.md' },
76
+ { file: 'references/modules.md' },
77
+ { file: 'references/checkout.md' },
78
+ { file: 'references/security.md' },
79
+ { file: 'references/parallel.md' },
80
+ ],
81
+ },
82
+ project: {
83
+ apiQuery: 'project resources',
84
+ references: [
85
+ { file: 'SKILL.md', headings: ROOT_SECTIONS },
86
+ { file: 'references/architecture.md' },
87
+ { file: 'references/modules.md' },
88
+ { file: 'references/mcp.md' },
89
+ { file: 'references/checkout.md' },
90
+ { file: 'references/parallel.md' },
91
+ ],
92
+ },
93
+ });
94
+
95
+ const TASK_ALIASES = Object.freeze({
96
+ backend: 'custom-service',
97
+ service: 'custom-service',
98
+ ui: 'frontend',
99
+ });
100
+
101
+ function normalizeTask(value) {
102
+ const requested = String(value || '').trim().toLowerCase();
103
+ const task = Object.prototype.hasOwnProperty.call(TASK_ALIASES, requested)
104
+ ? TASK_ALIASES[requested]
105
+ : requested;
106
+ if (!Object.prototype.hasOwnProperty.call(TASK_PROFILES, task)) {
107
+ const supported = Object.keys(TASK_PROFILES).join(', ');
108
+ throw new Error(`Unsupported context task: ${requested || '(empty)'}. Supported tasks: ${supported}.`);
109
+ }
110
+ return task;
111
+ }
112
+
113
+ function lineNumberAt(source, offset) {
114
+ let line = 1;
115
+ for (let index = 0; index < offset; index += 1) {
116
+ if (source.charCodeAt(index) === 10) line += 1;
117
+ }
118
+ return line;
119
+ }
120
+
121
+ function sectionEndLine(content, startLine) {
122
+ let newlines = 0;
123
+ for (let index = 0; index < content.length; index += 1) {
124
+ if (content.charCodeAt(index) === 10) newlines += 1;
125
+ }
126
+ return startLine + newlines - (content.endsWith('\n') ? 1 : 0);
127
+ }
128
+
129
+ function extractSections(source, requestedHeadings, sourcePath) {
130
+ const matches = [...source.matchAll(/^## ([^\r\n]+)\r?$/gm)];
131
+ const wanted = requestedHeadings ? new Set(requestedHeadings) : null;
132
+ const sections = [];
133
+
134
+ if (matches.length && matches[0].index > 0) {
135
+ const content = source.slice(0, matches[0].index);
136
+ const title = content.match(/^# ([^\r\n]+)\r?$/m);
137
+ sections.push({
138
+ source: {
139
+ kind: 'bundled_reference',
140
+ path: sourcePath,
141
+ heading: title ? title[1] : '(preamble)',
142
+ line_start: 1,
143
+ line_end: sectionEndLine(content, 1),
144
+ sha256: crypto.createHash('sha256').update(content, 'utf8').digest('hex'),
145
+ },
146
+ content,
147
+ });
148
+ }
149
+
150
+ for (let index = 0; index < matches.length; index += 1) {
151
+ const match = matches[index];
152
+ const heading = match[1];
153
+ if (wanted && !wanted.has(heading)) continue;
154
+ const start = match.index;
155
+ const end = index + 1 < matches.length ? matches[index + 1].index : source.length;
156
+ const content = source.slice(start, end);
157
+ const startLine = lineNumberAt(source, start);
158
+ sections.push({
159
+ source: {
160
+ kind: 'bundled_reference',
161
+ path: sourcePath,
162
+ heading,
163
+ line_start: startLine,
164
+ line_end: sectionEndLine(content, startLine),
165
+ sha256: crypto.createHash('sha256').update(content, 'utf8').digest('hex'),
166
+ },
167
+ content,
168
+ });
169
+ }
170
+
171
+ if (wanted) {
172
+ const found = new Set(matches.map((match) => match[1]));
173
+ const missing = requestedHeadings.filter((heading) => !found.has(heading));
174
+ if (missing.length) {
175
+ throw new Error(`${sourcePath} is missing required sections: ${missing.join(', ')}.`);
176
+ }
177
+ }
178
+ if (!sections.length) throw new Error(`${sourcePath} contains no selectable reference sections.`);
179
+ return sections;
180
+ }
181
+
182
+ function readTaskReferences(taskValue, options = {}) {
183
+ const task = normalizeTask(taskValue);
184
+ const skillRoot = options.skillRoot || SKILL_ROOT;
185
+ return TASK_PROFILES[task].references.flatMap((entry) => {
186
+ const absolute = path.join(skillRoot, entry.file);
187
+ const source = fs.readFileSync(absolute, 'utf8');
188
+ const sourcePath = path.posix.join('resources/skill', entry.file.replace(/\\/g, '/'));
189
+ return extractSections(source, entry.headings, sourcePath);
190
+ });
191
+ }
192
+
193
+ function rawQuery(session, expectedName, args, server, options = {}) {
194
+ const tool = session.names[expectedName] || expectedName;
195
+ return session.client.toolsCall(tool, args, options).then((result) => ({
196
+ source: {
197
+ kind: 'mcp_tool',
198
+ server,
199
+ tool,
200
+ canonical_tool: expectedName,
201
+ arguments: args,
202
+ },
203
+ result,
204
+ }));
205
+ }
206
+
207
+ function nextCursorFromRaw(result) {
208
+ let value = result;
209
+ if (value && value.result) value = value.result;
210
+ if (value && value.structuredContent) value = value.structuredContent;
211
+ else if (value && value.structured_content) value = value.structured_content;
212
+ else if (value && Array.isArray(value.content)) {
213
+ const text = value.content.find((part) =>
214
+ part && part.type === 'text' && typeof part.text === 'string');
215
+ if (text) {
216
+ if (Buffer.byteLength(text.text, 'utf8') > MAX_STRUCTURED_TEXT_BYTES) {
217
+ throw new Error('DraftGo MCP pagination result exceeded the structured JSON limit.');
218
+ }
219
+ try {
220
+ value = JSON.parse(text.text);
221
+ } catch {
222
+ throw new Error('DraftGo MCP pagination result did not contain structured JSON.');
223
+ }
224
+ }
225
+ }
226
+ if (value && value.data) value = value.data;
227
+ if (value && Object.prototype.hasOwnProperty.call(value, 'value')) value = value.value;
228
+ if (!value || typeof value !== 'object') return null;
229
+ if (Object.prototype.hasOwnProperty.call(value, 'next_cursor')) return value.next_cursor;
230
+ if (Object.prototype.hasOwnProperty.call(value, 'nextCursor')) return value.nextCursor;
231
+ return value.has_more && Object.prototype.hasOwnProperty.call(value, 'cursor')
232
+ ? value.cursor
233
+ : null;
234
+ }
235
+
236
+ async function rawPagedQuery(session, expectedName, baseArgs, server, options = {}) {
237
+ const pages = [];
238
+ const seen = new Set();
239
+ const maxPages = options.maxPages == null ? 100 : Number(options.maxPages);
240
+ if (!Number.isSafeInteger(maxPages) || maxPages < 1) {
241
+ throw new TypeError('maxPages must be a positive integer.');
242
+ }
243
+ let cursor = null;
244
+
245
+ for (let page = 0; page < maxPages; page += 1) {
246
+ const args = cursor == null ? { ...baseArgs } : { ...baseArgs, cursor };
247
+ const response = await rawQuery(session, expectedName, args, server, options);
248
+ pages.push(response);
249
+ const next = nextCursorFromRaw(response.result);
250
+ if (next == null || next === '') {
251
+ return {
252
+ source: {
253
+ kind: 'mcp_tool_sequence',
254
+ server,
255
+ canonical_tool: expectedName,
256
+ arguments: baseArgs,
257
+ },
258
+ pages,
259
+ };
260
+ }
261
+ const key = String(next);
262
+ if (seen.has(key)) throw new Error(expectedName + ' repeated a cursor.');
263
+ seen.add(key);
264
+ cursor = next;
265
+ }
266
+ throw new Error(expectedName + ' exceeded ' + maxPages + ' pages.');
267
+ }
268
+
269
+ async function collectDataStructures(session, server, options = {}) {
270
+ const [resources, api] = await Promise.all([
271
+ rawPagedQuery(
272
+ session,
273
+ TOOL_NAMES.resourceList,
274
+ { resource_type: 'db_meta' },
275
+ server,
276
+ options,
277
+ ),
278
+ rawQuery(
279
+ session,
280
+ TOOL_NAMES.apiSearch,
281
+ { query: 'db_meta schema' },
282
+ server,
283
+ options,
284
+ ),
285
+ ]);
286
+ return { resources, api };
287
+ }
288
+
289
+ async function collectContext(projectDir, taskValue, options = {}) {
290
+ const task = normalizeTask(taskValue);
291
+ const profile = TASK_PROFILES[task];
292
+ const references = readTaskReferences(task, options);
293
+ const installedSkillVersion = readInstalledSkillVersion(projectDir);
294
+ const config = options.config || loadProjectConfig(projectDir);
295
+ const expected = [TOOL_NAMES.projectOverview, TOOL_NAMES.resourceList, TOOL_NAMES.apiSearch];
296
+ const openSession = options.openToolSession || openToolSession;
297
+ const session = options.session || await openSession(config, expected, options);
298
+ const controller = new AbortController();
299
+ const externalSignal = options.signal;
300
+ const forwardAbort = () => controller.abort(externalSignal.reason);
301
+ if (externalSignal) {
302
+ if (externalSignal.aborted) forwardAbort();
303
+ else externalSignal.addEventListener('abort', forwardAbort, { once: true });
304
+ }
305
+
306
+ let project;
307
+ let resources;
308
+ let api;
309
+ let dataStructures;
310
+ try {
311
+ const queryOptions = { ...options, signal: controller.signal };
312
+ [project, resources, api, dataStructures] = await Promise.all([
313
+ rawQuery(session, TOOL_NAMES.projectOverview, {}, config.server, queryOptions),
314
+ rawPagedQuery(session, TOOL_NAMES.resourceList, {}, config.server, queryOptions),
315
+ rawQuery(
316
+ session,
317
+ TOOL_NAMES.apiSearch,
318
+ { query: profile.apiQuery },
319
+ config.server,
320
+ queryOptions,
321
+ ),
322
+ collectDataStructures(session, config.server, queryOptions),
323
+ ]);
324
+ } catch (error) {
325
+ if (!controller.signal.aborted) controller.abort(error);
326
+ throw error;
327
+ } finally {
328
+ if (externalSignal) externalSignal.removeEventListener('abort', forwardAbort);
329
+ }
330
+
331
+ return {
332
+ schema_version: '1.0',
333
+ task,
334
+ reference_bundle: {
335
+ source: 'draftgo-cli',
336
+ version: pkg.version,
337
+ installed_skill_version: installedSkillVersion,
338
+ synchronized: installedSkillVersion == null ? null : installedSkillVersion === pkg.version,
339
+ },
340
+ references,
341
+ live: {
342
+ session: {
343
+ server: config.server,
344
+ protocol_version: session.client.protocolVersion,
345
+ },
346
+ project,
347
+ resources,
348
+ api,
349
+ data_structures: dataStructures,
350
+ },
351
+ };
352
+ }
353
+
354
+ function readInstalledSkillVersion(projectDir) {
355
+ return readInstalledVersion(projectDir);
356
+ }
357
+
358
+ module.exports = {
359
+ TASK_PROFILES,
360
+ normalizeTask,
361
+ extractSections,
362
+ readTaskReferences,
363
+ nextCursorFromRaw,
364
+ rawPagedQuery,
365
+ collectDataStructures,
366
+ readInstalledSkillVersion,
367
+ collectContext,
368
+ };
package/src/skill.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // {{SKILL_DIR}} → platform's project-relative skill dir (forward slashes)
10
10
  // {{SKILL_SCRIPTS}} → {{SKILL_DIR}}/scripts
11
11
 
12
+ const crypto = require('crypto');
12
13
  const path = require('path');
13
14
  const fs = require('fs');
14
15
  const { RESOURCES_DIR } = require('./paths');
@@ -19,6 +20,8 @@ const {
19
20
  } = require('./fsx');
20
21
 
21
22
  const SKILL_SOURCE_DIR = path.join(RESOURCES_DIR, 'skill');
23
+ const MAX_VERSION_FILE_BYTES = 128;
24
+ const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
22
25
 
23
26
  function getPackageVersion() {
24
27
  try {
@@ -29,12 +32,71 @@ function getPackageVersion() {
29
32
  }
30
33
 
31
34
  function readInstalledVersion(projectDir) {
32
- const f = paths.versionFile(projectDir);
33
- return exists(f) ? readText(f).trim() : null;
35
+ const file = paths.versionFile(projectDir);
36
+ let linkStat;
37
+ try {
38
+ linkStat = fs.lstatSync(file);
39
+ } catch (error) {
40
+ if (error && error.code === 'ENOENT') return null;
41
+ throw error;
42
+ }
43
+ if (linkStat.isSymbolicLink() || !linkStat.isFile()) {
44
+ throw new Error('Invalid .draftgo/.version: expected a regular file.');
45
+ }
46
+
47
+ let descriptor;
48
+ let raw;
49
+ try {
50
+ const noFollow = Number.isInteger(fs.constants.O_NOFOLLOW) ? fs.constants.O_NOFOLLOW : 0;
51
+ descriptor = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
52
+ const stat = fs.fstatSync(descriptor);
53
+ if (!stat.isFile() || stat.size > MAX_VERSION_FILE_BYTES) {
54
+ throw new Error('Invalid .draftgo/.version: file is too large or not regular.');
55
+ }
56
+ const buffer = Buffer.alloc(MAX_VERSION_FILE_BYTES + 1);
57
+ const bytes = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
58
+ if (bytes > MAX_VERSION_FILE_BYTES) {
59
+ throw new Error('Invalid .draftgo/.version: file exceeds 128 bytes.');
60
+ }
61
+ raw = buffer.subarray(0, bytes).toString('utf8');
62
+ } catch (error) {
63
+ if (error && error.code === 'ELOOP') {
64
+ throw new Error('Invalid .draftgo/.version: symbolic links are not allowed.');
65
+ }
66
+ throw error;
67
+ } finally {
68
+ if (descriptor !== undefined) fs.closeSync(descriptor);
69
+ }
70
+
71
+ const version = raw.trim();
72
+ const validLineEnding = raw === version || raw === `${version}\n` || raw === `${version}\r\n`;
73
+ if (!validLineEnding || !VERSION_PATTERN.test(version)) {
74
+ throw new Error('Invalid .draftgo/.version: expected one semantic version.');
75
+ }
76
+ return version;
34
77
  }
35
78
 
36
79
  function writeInstalledVersion(projectDir) {
37
- writeText(paths.versionFile(projectDir), getPackageVersion() + '\n');
80
+ const file = paths.versionFile(projectDir);
81
+ ensureDir(path.dirname(file));
82
+ const temporary = path.join(
83
+ path.dirname(file),
84
+ `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`,
85
+ );
86
+ let descriptor;
87
+ try {
88
+ descriptor = fs.openSync(temporary, 'wx', 0o600);
89
+ fs.writeFileSync(descriptor, getPackageVersion() + '\n', 'utf8');
90
+ fs.fsyncSync(descriptor);
91
+ fs.closeSync(descriptor);
92
+ descriptor = undefined;
93
+ fs.renameSync(temporary, file);
94
+ } finally {
95
+ if (descriptor !== undefined) {
96
+ try { fs.closeSync(descriptor); } catch { /* Preserve the original failure. */ }
97
+ }
98
+ try { fs.rmSync(temporary, { force: true }); } catch { /* Best-effort cleanup. */ }
99
+ }
38
100
  }
39
101
 
40
102
  function renderFrontmatter(fm) {