draftgo-cli 3.0.39 → 3.0.43

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.
package/src/mcp/client.js CHANGED
@@ -7,11 +7,14 @@ const {
7
7
  postJsonRpc,
8
8
  redactText,
9
9
  } = require('./protocol');
10
+ const { allWithAbort } = require('./parallel');
10
11
 
11
12
  const SAFE_TEST_TOOLS = [
12
13
  'draftgo_project_overview',
13
14
  'draftgo_resource_list',
14
15
  'draftgo_api_search',
16
+ 'draftgo_api_describe',
17
+ 'draftgo_api_call',
15
18
  ];
16
19
 
17
20
  const REQUIRED_DRAFTGO_TOOLS = [
@@ -25,6 +28,33 @@ const REQUIRED_DRAFTGO_TOOLS = [
25
28
  'draftgo_api_call',
26
29
  ];
27
30
 
31
+ function diagnosticArguments(canonicalName) {
32
+ if (canonicalName === 'draftgo_resource_list') {
33
+ return { resource_type: 'pages', limit: 1 };
34
+ }
35
+ if (canonicalName === 'draftgo_api_search') return { query: 'project', limit: 1 };
36
+ return {};
37
+ }
38
+
39
+ function diagnosticData(result) {
40
+ let value = result;
41
+ if (value && value.structuredContent) value = value.structuredContent;
42
+ else if (value && value.structured_content) value = value.structured_content;
43
+ if (value && value.data) value = value.data;
44
+ if (value && Object.prototype.hasOwnProperty.call(value, 'value')) value = value.value;
45
+ return value;
46
+ }
47
+
48
+ function findDBMetaListOperation(result) {
49
+ const value = diagnosticData(result);
50
+ const items = value && Array.isArray(value.items) ? value.items : [];
51
+ return items.find((operation) => operation
52
+ && String(operation.method || '').toUpperCase() === 'GET'
53
+ && String(operation.resource_type || '').toLowerCase() === 'db_meta'
54
+ && String(operation.path || operation.path_template || '') === '/api/db-meta'
55
+ && operation.destructive !== true) || null;
56
+ }
57
+
28
58
  class McpRpcError extends Error {
29
59
  constructor(message, { code = -32000, data, id = null } = {}) {
30
60
  super(message);
@@ -35,6 +65,40 @@ class McpRpcError extends Error {
35
65
  }
36
66
  }
37
67
 
68
+ function diagnosticNextCursor(result) {
69
+ const value = diagnosticData(result);
70
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
71
+ const hasMore = value.has_more === true || value.hasMore === true;
72
+
73
+ let cursor;
74
+ let present = false;
75
+ if (Object.prototype.hasOwnProperty.call(value, 'next_cursor')) {
76
+ cursor = value.next_cursor;
77
+ present = true;
78
+ } else if (Object.prototype.hasOwnProperty.call(value, 'nextCursor')) {
79
+ cursor = value.nextCursor;
80
+ present = true;
81
+ } else if (hasMore) {
82
+ if (!Object.prototype.hasOwnProperty.call(value, 'cursor')) {
83
+ throw new McpRpcError('DraftGo api_search reported more pages without a cursor.');
84
+ }
85
+ cursor = value.cursor;
86
+ present = true;
87
+ }
88
+
89
+ if (!present) return null;
90
+ if (cursor == null || cursor === '') {
91
+ if (hasMore) {
92
+ throw new McpRpcError('DraftGo api_search reported more pages with an empty cursor.');
93
+ }
94
+ return null;
95
+ }
96
+ if (typeof cursor !== 'string' || !cursor.trim()) {
97
+ throw new McpRpcError('DraftGo api_search returned an invalid cursor.');
98
+ }
99
+ return cursor;
100
+ }
101
+
38
102
  function redactValue(value, secrets = [], seen = new WeakMap()) {
39
103
  if (typeof value === 'string') return redactText(value, secrets);
40
104
  if (!value || typeof value !== 'object') return value;
@@ -222,30 +286,118 @@ class DraftGoMcpClient {
222
286
  if (missing.length) {
223
287
  throw new McpRpcError(`DraftGo MCP is missing required tools: ${missing.join(', ')}.`, { code: -32601 });
224
288
  }
225
- const required = options.safeTools || SAFE_TEST_TOOLS;
226
- const selected = tools.find((tool) => tool && typeof tool.name === 'string'
227
- && required.some((candidate) => toolMatches(tool.name, candidate)));
228
- if (!selected) {
289
+ const safeTools = options.safeTools || SAFE_TEST_TOOLS;
290
+ const hasDiagnosticOverrides = Boolean(options.requiredTools || options.safeTools);
291
+ const firstSafeTool = tools.find((tool) => tool && typeof tool.name === 'string'
292
+ && safeTools.some((canonical) => toolMatches(tool.name, canonical)));
293
+ if (!firstSafeTool) {
229
294
  throw new McpRpcError(
230
- `DraftGo MCP exposes no safe diagnostic tool (${required.join(', ')}).`,
295
+ `DraftGo MCP exposes no safe diagnostic tool (${safeTools.join(', ')}).`,
231
296
  { code: -32601 },
232
297
  );
233
298
  }
234
299
 
235
- const canonical = required.find((candidate) => toolMatches(selected.name, candidate));
236
- const defaultArguments = canonical === 'draftgo_api_search' ? { query: 'project' } : {};
237
- const toolResult = await this.toolsCall(
238
- selected.name,
239
- options.toolArguments || defaultArguments,
240
- options,
241
- );
300
+ const selected = safeTools.map((canonical) => ({
301
+ canonical,
302
+ tool: tools.find((candidate) => candidate && typeof candidate.name === 'string'
303
+ && toolMatches(candidate.name, canonical)),
304
+ })).filter((entry) => entry.tool);
305
+ const byCanonical = Object.fromEntries(selected.map((entry) => [entry.canonical, entry.tool]));
306
+ const overrideCanonical = safeTools.find((canonical) => toolMatches(firstSafeTool.name, canonical));
307
+ const defaultPlan = hasDiagnosticOverrides ? [{
308
+ label: overrideCanonical,
309
+ canonical: overrideCanonical,
310
+ tool: firstSafeTool,
311
+ args: diagnosticArguments(overrideCanonical),
312
+ }] : [
313
+ { label: 'project', canonical: 'draftgo_project_overview', tool: byCanonical.draftgo_project_overview, args: {} },
314
+ ...['pages', 'navigations', 'docs/articles'].map((resourceType) => ({
315
+ label: `resource:${resourceType}`,
316
+ canonical: 'draftgo_resource_list',
317
+ tool: byCanonical.draftgo_resource_list,
318
+ args: { resource_type: resourceType, limit: 1 },
319
+ })),
320
+ {
321
+ label: 'api:db_meta',
322
+ canonical: 'draftgo_api_search',
323
+ tool: byCanonical.draftgo_api_search,
324
+ args: { resource_type: 'db_meta', limit: 100 },
325
+ },
326
+ ];
327
+ const runPlan = (plan, startIndex = 0) => allWithAbort(plan.map((entry, offset) =>
328
+ async (queryOptions) => ({
329
+ label: entry.label,
330
+ canonical: entry.canonical,
331
+ name: entry.tool.name,
332
+ arguments: options.toolArguments && startIndex + offset === 0
333
+ ? options.toolArguments
334
+ : entry.args,
335
+ result: await this.toolsCall(
336
+ entry.tool.name,
337
+ options.toolArguments && startIndex + offset === 0
338
+ ? options.toolArguments
339
+ : entry.args,
340
+ queryOptions,
341
+ ),
342
+ })), options);
343
+ const tested = await runPlan(defaultPlan);
344
+ if (!hasDiagnosticOverrides) {
345
+ let discovery = tested.find((entry) => entry.label === 'api:db_meta');
346
+ let operation = discovery && findDBMetaListOperation(discovery.result);
347
+ const seenCursors = new Set();
348
+ const maxPages = Number(options.maxPages || 100);
349
+ let page = 1;
350
+ while (!operation) {
351
+ const cursor = diagnosticNextCursor(discovery && discovery.result);
352
+ if (cursor == null) break;
353
+ if (seenCursors.has(cursor)) {
354
+ throw new McpRpcError('DraftGo api_search repeated a db_meta cursor.');
355
+ }
356
+ seenCursors.add(cursor);
357
+ if (page >= maxPages) {
358
+ throw new McpRpcError(`DraftGo api_search exceeded ${maxPages} db_meta pages.`);
359
+ }
360
+ page += 1;
361
+ [discovery] = await runPlan([{
362
+ label: `api:db_meta:page-${page}`,
363
+ canonical: 'draftgo_api_search',
364
+ tool: byCanonical.draftgo_api_search,
365
+ args: { resource_type: 'db_meta', limit: 100, cursor },
366
+ }], tested.length);
367
+ tested.push(discovery);
368
+ operation = findDBMetaListOperation(discovery.result);
369
+ }
370
+ if (!operation || !operation.operation_id) {
371
+ throw new McpRpcError('DraftGo MCP exposes no read-only GET /api/db-meta operation.', {
372
+ code: -32601,
373
+ });
374
+ }
375
+ const operationID = String(operation.operation_id);
376
+ tested.push(...await runPlan([
377
+ {
378
+ label: 'api:describe-db_meta',
379
+ canonical: 'draftgo_api_describe',
380
+ tool: byCanonical.draftgo_api_describe,
381
+ args: { operation_id: operationID },
382
+ },
383
+ {
384
+ label: 'api:call-db_meta',
385
+ canonical: 'draftgo_api_call',
386
+ tool: byCanonical.draftgo_api_call,
387
+ args: { operation_id: operationID, query: { page: 1, page_size: 1 } },
388
+ },
389
+ ], tested.length));
390
+ }
242
391
  return {
243
392
  initialized,
244
393
  protocolVersion: this.protocolVersion,
245
394
  sessionId: this.sessionId,
246
395
  tools,
247
- testedTool: selected.name,
248
- toolResult,
396
+ testedTool: tested[0].name,
397
+ toolResult: tested[0].result,
398
+ testedTools: [...new Set(tested.map((entry) => entry.name))],
399
+ testedCalls: tested.map((entry) => entry.label),
400
+ diagnosticResults: tested,
249
401
  };
250
402
  }
251
403
  }
@@ -268,6 +420,10 @@ module.exports = {
268
420
  McpHttpError,
269
421
  SAFE_TEST_TOOLS,
270
422
  REQUIRED_DRAFTGO_TOOLS,
423
+ diagnosticArguments,
424
+ diagnosticData,
425
+ diagnosticNextCursor,
426
+ findDBMetaListOperation,
271
427
  redactValue,
272
428
  testConnection,
273
429
  callTool,
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ async function allWithAbort(taskFactories, options = {}) {
4
+ if (!Array.isArray(taskFactories) || taskFactories.some((task) => typeof task !== 'function')) {
5
+ throw new TypeError('Parallel MCP tasks must be functions.');
6
+ }
7
+ const externalSignal = options.signal;
8
+ if (externalSignal && externalSignal.aborted) {
9
+ if (typeof externalSignal.throwIfAborted === 'function') externalSignal.throwIfAborted();
10
+ const error = new Error('Parallel MCP tasks were aborted before they started.');
11
+ error.name = 'AbortError';
12
+ throw error;
13
+ }
14
+
15
+ const controller = new AbortController();
16
+ const forwardAbort = () => controller.abort(externalSignal.reason);
17
+ if (externalSignal) {
18
+ externalSignal.addEventListener('abort', forwardAbort, { once: true });
19
+ }
20
+ try {
21
+ const taskOptions = { ...options, signal: controller.signal };
22
+ return await Promise.all(taskFactories.map((task) => task(taskOptions)));
23
+ } catch (error) {
24
+ if (!controller.signal.aborted) controller.abort(error);
25
+ throw error;
26
+ } finally {
27
+ if (externalSignal) externalSignal.removeEventListener('abort', forwardAbort);
28
+ }
29
+ }
30
+
31
+ module.exports = { allWithAbort };
@@ -22,9 +22,10 @@ function redactText(value, secrets = []) {
22
22
 
23
23
  function endpointFor(config) {
24
24
  const server = new URL(String(config.server || config.mcp_url));
25
+ const serverPath = server.pathname.replace(/\/+$/, '');
25
26
  const endpoint = config.mcp_url
26
27
  ? new URL(String(config.mcp_url), server)
27
- : new URL(`${server.pathname.replace(/\/+$/, '')}/mcp`, server);
28
+ : new URL(`${serverPath}/mcp`, server);
28
29
  if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password) {
29
30
  throw new McpHttpError('DraftGo MCP endpoint must be a credential-free HTTP(S) URL.');
30
31
  }
@@ -11,16 +11,42 @@ const IGNORE_ENTRIES = [
11
11
  '.draftgo/conflicts/',
12
12
  ];
13
13
 
14
- function normalizeServer(raw) {
14
+ function parseHttpUrl(raw) {
15
15
  let value = String(raw || '').trim();
16
- if (!value) return '';
16
+ if (!value) return null;
17
17
  if (!/^https?:\/\//i.test(value)) value = `http://${value}`;
18
18
  const parsed = new URL(value);
19
19
  if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password
20
20
  || parsed.search || parsed.hash) {
21
21
  throw new Error('DraftGo server must be an absolute HTTP(S) URL without credentials, query, or fragment.');
22
22
  }
23
- return value.replace(/\/+$/, '');
23
+ return parsed;
24
+ }
25
+
26
+ function normalizeServer(raw) {
27
+ const parsed = parseHttpUrl(raw);
28
+ if (!parsed) return '';
29
+ return parsed.toString().replace(/\/+$/, '');
30
+ }
31
+
32
+ function normalizeMcpEndpoint(raw) {
33
+ const parsed = parseHttpUrl(raw);
34
+ return parsed ? parsed.toString() : '';
35
+ }
36
+
37
+ function normalizeConnection(raw) {
38
+ const value = normalizeMcpEndpoint(raw);
39
+ if (!value) return { server: '', mcp_url: '' };
40
+ const parsed = new URL(value);
41
+ const pathname = parsed.pathname.replace(/\/+$/, '');
42
+ if (!pathname.toLowerCase().endsWith('/mcp')) {
43
+ return { server: '', mcp_url: value };
44
+ }
45
+ parsed.pathname = pathname.slice(0, -4) || '/';
46
+ return {
47
+ server: parsed.toString().replace(/\/+$/, ''),
48
+ mcp_url: value,
49
+ };
24
50
  }
25
51
 
26
52
  function configPath(projectDir) {
@@ -65,7 +91,7 @@ function writePrivateJson(file, value) {
65
91
  try { fs.chmodSync(file, 0o600); } catch { /* Windows ACLs are inherited. */ }
66
92
  }
67
93
 
68
- function writeProjectConfig(projectDir, server, token) {
94
+ function writeProjectConfig(projectDir, server, token, options = {}) {
69
95
  const normalizedServer = normalizeServer(server);
70
96
  const sat = String(token || '').trim();
71
97
  if (!normalizedServer) throw new Error('DraftGo server is required.');
@@ -77,6 +103,8 @@ function writeProjectConfig(projectDir, server, token) {
77
103
  token: sat,
78
104
  auto_push: existing.auto_push === true,
79
105
  };
106
+ if (options.mcp_url) config.mcp_url = normalizeMcpEndpoint(options.mcp_url);
107
+ else delete config.mcp_url;
80
108
  const file = configPath(projectDir);
81
109
  writePrivateJson(file, config);
82
110
  ensureProjectIgnores(projectDir);
@@ -88,16 +116,22 @@ function loadProjectConfig(projectDir, { requireToken = true } = {}) {
88
116
  if (!exists(file)) throw new Error('Missing .draftgo/config.json; run `draftgo connect` first.');
89
117
  const config = readExisting(projectDir);
90
118
  const server = normalizeServer(config.server);
119
+ const mcpUrl = config.mcp_url ? normalizeMcpEndpoint(config.mcp_url) : '';
91
120
  const token = String(config.token || config.sat || '').trim();
92
121
  if (!server) throw new Error('DraftGo project config is missing server.');
93
122
  if (requireToken && !token) throw new Error('DraftGo project config is missing SAT.');
94
- return { ...config, server, token, path: file };
123
+ const result = { ...config, server, token, path: file };
124
+ if (mcpUrl) result.mcp_url = mcpUrl;
125
+ else delete result.mcp_url;
126
+ return result;
95
127
  }
96
128
 
97
129
  module.exports = {
98
130
  IGNORE_ENTRIES,
99
131
  configPath,
100
132
  normalizeServer,
133
+ normalizeMcpEndpoint,
134
+ normalizeConnection,
101
135
  ensureProjectIgnores,
102
136
  writeProjectConfig,
103
137
  loadProjectConfig,
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) {
package/src/timeout.js ADDED
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+
3
+ const MAX_TIMEOUT_MS = 2_147_483_647;
4
+
5
+ function parseTimeout(value) {
6
+ if (value == null) return undefined;
7
+ const text = String(value).trim();
8
+ if (!/^\d+$/.test(text)) {
9
+ throw new TypeError('--timeout must be a positive integer in milliseconds.');
10
+ }
11
+ const timeoutMs = Number(text);
12
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) {
13
+ throw new TypeError(`--timeout must be between 1 and ${MAX_TIMEOUT_MS} milliseconds.`);
14
+ }
15
+ return timeoutMs;
16
+ }
17
+
18
+ module.exports = { MAX_TIMEOUT_MS, parseTimeout };