draftgo-cli 3.0.35 → 3.0.38
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/README.md +220 -272
- package/package.json +6 -2
- package/resources/skill/SKILL.md +114 -55
- package/resources/skill/init/SKILL.md +29 -15
- package/resources/skill/manifest.json +5 -4
- package/resources/skill/push/SKILL.md +41 -29
- package/resources/skill/references/aihub.md +8 -5
- package/resources/skill/references/api-endpoints.md +5 -3
- package/resources/skill/references/architecture.md +1 -1
- package/resources/skill/references/checkout.md +116 -0
- package/resources/skill/references/custom-services.md +9 -10
- package/resources/skill/references/data.md +4 -2
- package/resources/skill/references/frontend.md +1 -1
- package/resources/skill/references/mcp.md +101 -0
- package/resources/skill/references/modules.md +8 -8
- package/resources/skill/references/parallel.md +6 -3
- package/resources/skill/references/runtime.md +7 -10
- package/resources/skill/scripts/README.md +8 -0
- package/resources/skill/story/SKILL.md +8 -8
- package/src/cli.js +5 -0
- package/src/commandRegistry.js +7 -1
- package/src/commands/api.js +24 -187
- package/src/commands/autoPush.js +48 -17
- package/src/commands/check.js +17 -47
- package/src/commands/checkout.js +18 -0
- package/src/commands/commit.js +21 -0
- package/src/commands/conflict.js +30 -0
- package/src/commands/conflicts.js +16 -0
- package/src/commands/connect.js +60 -48
- package/src/commands/delete.js +79 -64
- package/src/commands/deploy.js +18 -10
- package/src/commands/diff.js +23 -0
- package/src/commands/help.js +99 -75
- package/src/commands/init.js +4 -10
- package/src/commands/local.js +23 -6
- package/src/commands/map.js +89 -89
- package/src/commands/mcp.js +126 -0
- package/src/commands/sync.js +28 -43
- package/src/commands/verifyUi.js +3 -2
- package/src/localdev/index.js +37 -7
- package/src/localdev/mysqlClient.js +1 -1
- package/src/mcp/client.js +275 -0
- package/src/mcp/hosts.js +520 -0
- package/src/mcp/protocol.js +173 -0
- package/src/mcp/stdio.js +300 -0
- package/src/mcp/tools.js +37 -0
- package/src/platforms.js +3 -4
- package/src/projectConfig.js +91 -49
- package/src/projectMap.js +123 -460
- package/src/skill.js +6 -28
- package/src/worktree/backend.js +250 -0
- package/src/worktree/errors.js +28 -0
- package/src/worktree/index.js +461 -0
- package/src/worktree/manifest.js +75 -0
- package/src/worktree/streams.js +200 -0
- package/src/worktree/types.js +103 -0
- package/src/worktree/validate.js +37 -0
- package/resources/skill/pull/SKILL.md +0 -33
- package/resources/skill/references/api.json +0 -20248
- package/resources/skill/scripts/draftgo_delete.py +0 -149
- package/resources/skill/scripts/draftgo_init.py +0 -80
- package/resources/skill/scripts/draftgo_pull.py +0 -427
- package/resources/skill/scripts/draftgo_push.py +0 -1022
- package/src/python.js +0 -27
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { once } = require('events');
|
|
7
|
+
const { WorktreeError } = require('./errors');
|
|
8
|
+
|
|
9
|
+
function normalizeSha256(value) {
|
|
10
|
+
if (value == null || value === '') return null;
|
|
11
|
+
const normalized = String(value).trim().toLowerCase().replace(/^sha-?256[:=]/, '');
|
|
12
|
+
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function tempPathFor(destination) {
|
|
16
|
+
const suffix = crypto.randomBytes(8).toString('hex');
|
|
17
|
+
return path.join(
|
|
18
|
+
path.dirname(destination),
|
|
19
|
+
`.${path.basename(destination)}.${process.pid}.${suffix}.tmp`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function ensureParent(filePath) {
|
|
24
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function syncDirectory(directory) {
|
|
28
|
+
let handle;
|
|
29
|
+
try {
|
|
30
|
+
handle = await fs.promises.open(directory, 'r');
|
|
31
|
+
await handle.sync();
|
|
32
|
+
} catch {
|
|
33
|
+
// Directory fsync is unavailable on some Windows filesystems.
|
|
34
|
+
} finally {
|
|
35
|
+
if (handle) await handle.close().catch(() => {});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function bodyIterable(body) {
|
|
40
|
+
if (!body) throw new WorktreeError('DOWNLOAD_INCOMPLETE', 'The content response did not include a body.');
|
|
41
|
+
if (typeof body[Symbol.asyncIterator] === 'function') return body;
|
|
42
|
+
if (typeof body.getReader === 'function') {
|
|
43
|
+
return {
|
|
44
|
+
async *[Symbol.asyncIterator]() {
|
|
45
|
+
const reader = body.getReader();
|
|
46
|
+
try {
|
|
47
|
+
while (true) {
|
|
48
|
+
const { done, value } = await reader.read();
|
|
49
|
+
if (done) return;
|
|
50
|
+
yield value;
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
reader.releaseLock();
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
throw new WorktreeError('DOWNLOAD_INCOMPLETE', 'The content response body is not streamable.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function writeChunk(stream, chunk) {
|
|
62
|
+
if (stream.write(chunk)) return;
|
|
63
|
+
await once(stream, 'drain');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function closeStream(stream) {
|
|
67
|
+
stream.end();
|
|
68
|
+
await once(stream, 'close');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function streamToFiles(body, destinations, options = {}) {
|
|
72
|
+
const unique = [...new Set(destinations.map((item) => path.resolve(item)))];
|
|
73
|
+
if (!unique.length) throw new WorktreeError('INVALID_DESTINATION', 'At least one destination is required.');
|
|
74
|
+
await Promise.all(unique.map(ensureParent));
|
|
75
|
+
|
|
76
|
+
const temporaries = unique.map((destination) => ({
|
|
77
|
+
destination,
|
|
78
|
+
temporary: tempPathFor(destination),
|
|
79
|
+
backup: `${tempPathFor(destination)}.bak`,
|
|
80
|
+
stream: null,
|
|
81
|
+
backedUp: false,
|
|
82
|
+
installed: false,
|
|
83
|
+
}));
|
|
84
|
+
const hash = crypto.createHash('sha256');
|
|
85
|
+
let size = 0;
|
|
86
|
+
let complete = false;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
for (const item of temporaries) {
|
|
90
|
+
item.stream = fs.createWriteStream(item.temporary, { flags: 'wx', mode: 0o600 });
|
|
91
|
+
await once(item.stream, 'open');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for await (const rawChunk of bodyIterable(body)) {
|
|
95
|
+
const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk);
|
|
96
|
+
hash.update(chunk);
|
|
97
|
+
size += chunk.length;
|
|
98
|
+
for (const item of temporaries) await writeChunk(item.stream, chunk);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const item of temporaries) {
|
|
102
|
+
await new Promise((resolve, reject) => item.stream.end((error) => error ? reject(error) : resolve()));
|
|
103
|
+
await fs.promises.open(item.temporary, 'r+').then(async (handle) => {
|
|
104
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const actualHash = hash.digest('hex');
|
|
109
|
+
const expectedHash = normalizeSha256(options.expectedHash);
|
|
110
|
+
if (options.expectedHash && !expectedHash) {
|
|
111
|
+
throw new WorktreeError('INVALID_CONTENT_HASH', 'The backend returned an invalid SHA-256 hash.');
|
|
112
|
+
}
|
|
113
|
+
if (expectedHash && actualHash !== expectedHash) {
|
|
114
|
+
throw new WorktreeError('HASH_MISMATCH', 'Downloaded content did not match its SHA-256 metadata.', {
|
|
115
|
+
expected_hash: expectedHash,
|
|
116
|
+
actual_hash: actualHash,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (options.expectedSize != null && Number(options.expectedSize) !== size) {
|
|
120
|
+
throw new WorktreeError('DOWNLOAD_INCOMPLETE', 'Downloaded content size did not match its metadata.', {
|
|
121
|
+
expected_size: Number(options.expectedSize),
|
|
122
|
+
actual_size: size,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
for (const item of temporaries) {
|
|
128
|
+
if (fs.existsSync(item.destination)) {
|
|
129
|
+
await fs.promises.rename(item.destination, item.backup);
|
|
130
|
+
item.backedUp = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const item of temporaries) {
|
|
134
|
+
await fs.promises.rename(item.temporary, item.destination);
|
|
135
|
+
item.installed = true;
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
for (const item of [...temporaries].reverse()) {
|
|
139
|
+
if (item.installed) await fs.promises.rm(item.destination, { force: true }).catch(() => {});
|
|
140
|
+
if (item.backedUp) await fs.promises.rename(item.backup, item.destination).catch(() => {});
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
await Promise.all([...new Set(unique.map(path.dirname))].map(syncDirectory));
|
|
145
|
+
await Promise.all(temporaries.map((item) => fs.promises.rm(item.backup, { force: true })));
|
|
146
|
+
complete = true;
|
|
147
|
+
return { hash: actualHash, size };
|
|
148
|
+
} finally {
|
|
149
|
+
for (const item of temporaries) {
|
|
150
|
+
if (item.stream && !item.stream.closed) item.stream.destroy();
|
|
151
|
+
if (!complete) await fs.promises.rm(item.temporary, { force: true }).catch(() => {});
|
|
152
|
+
if (!complete && item.backedUp && fs.existsSync(item.backup) && !fs.existsSync(item.destination)) {
|
|
153
|
+
await fs.promises.rename(item.backup, item.destination).catch(() => {});
|
|
154
|
+
}
|
|
155
|
+
if (complete) await fs.promises.rm(item.backup, { force: true }).catch(() => {});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function hashFile(filePath) {
|
|
161
|
+
const hash = crypto.createHash('sha256');
|
|
162
|
+
let size = 0;
|
|
163
|
+
const stream = fs.createReadStream(filePath);
|
|
164
|
+
for await (const chunk of stream) {
|
|
165
|
+
hash.update(chunk);
|
|
166
|
+
size += chunk.length;
|
|
167
|
+
}
|
|
168
|
+
return { hash: hash.digest('hex'), size };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function copyFileAtomic(source, destination, options = {}) {
|
|
172
|
+
return streamToFiles(fs.createReadStream(source), [destination], options);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function writeJsonAtomic(destination, value) {
|
|
176
|
+
await ensureParent(destination);
|
|
177
|
+
const temporary = tempPathFor(destination);
|
|
178
|
+
let handle;
|
|
179
|
+
try {
|
|
180
|
+
handle = await fs.promises.open(temporary, 'wx', 0o600);
|
|
181
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
182
|
+
await handle.sync();
|
|
183
|
+
await handle.close();
|
|
184
|
+
handle = null;
|
|
185
|
+
await fs.promises.rename(temporary, destination);
|
|
186
|
+
await syncDirectory(path.dirname(destination));
|
|
187
|
+
} finally {
|
|
188
|
+
if (handle) await handle.close().catch(() => {});
|
|
189
|
+
await fs.promises.rm(temporary, { force: true }).catch(() => {});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
normalizeSha256,
|
|
195
|
+
tempPathFor,
|
|
196
|
+
streamToFiles,
|
|
197
|
+
hashFile,
|
|
198
|
+
copyFileAtomic,
|
|
199
|
+
writeJsonAtomic,
|
|
200
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { WorktreeError } = require('./errors');
|
|
5
|
+
|
|
6
|
+
const RESOURCE_TYPES = Object.freeze({
|
|
7
|
+
pages: Object.freeze({ directory: 'pages', prefix: 'page' }),
|
|
8
|
+
navigations: Object.freeze({ directory: 'navigations', prefix: 'nav' }),
|
|
9
|
+
docs: Object.freeze({ directory: 'docs', prefix: 'article' }),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const TYPE_ALIASES = new Map([
|
|
13
|
+
['page', 'pages'],
|
|
14
|
+
['pages', 'pages'],
|
|
15
|
+
['nav', 'navigations'],
|
|
16
|
+
['navigation', 'navigations'],
|
|
17
|
+
['navigations', 'navigations'],
|
|
18
|
+
['doc', 'docs'],
|
|
19
|
+
['docs', 'docs'],
|
|
20
|
+
['article', 'docs'],
|
|
21
|
+
['articles', 'docs'],
|
|
22
|
+
['docs/articles', 'docs'],
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const CONTENT_EXTENSIONS = new Map([
|
|
26
|
+
['text/html', '.html'],
|
|
27
|
+
['application/xhtml+xml', '.html'],
|
|
28
|
+
['text/markdown', '.md'],
|
|
29
|
+
['text/x-markdown', '.md'],
|
|
30
|
+
['text/plain', '.txt'],
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
function canonicalResourceType(value) {
|
|
34
|
+
const key = String(value || '').trim().toLowerCase().replace(/\\/g, '/');
|
|
35
|
+
const canonical = TYPE_ALIASES.get(key);
|
|
36
|
+
if (!canonical) {
|
|
37
|
+
throw new WorktreeError(
|
|
38
|
+
'UNSUPPORTED_RESOURCE_TYPE',
|
|
39
|
+
`Unsupported checkout resource type: ${value}. Expected pages, nav, or docs.`,
|
|
40
|
+
{ resource_type: value }
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return canonical;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mediaType(contentType) {
|
|
47
|
+
return String(contentType || '').split(';', 1)[0].trim().toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeExtension(contentType, backendExtension) {
|
|
51
|
+
const known = CONTENT_EXTENSIONS.get(mediaType(contentType));
|
|
52
|
+
if (known) return known;
|
|
53
|
+
if (backendExtension == null || backendExtension === '') {
|
|
54
|
+
throw new WorktreeError(
|
|
55
|
+
'MISSING_FILE_EXTENSION',
|
|
56
|
+
'DraftGo must provide a safe file extension for this content type.',
|
|
57
|
+
{ content_type: contentType },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
let extension = String(backendExtension).trim();
|
|
61
|
+
if (!extension.startsWith('.')) extension = `.${extension}`;
|
|
62
|
+
if (!/^\.[A-Za-z0-9][A-Za-z0-9._-]{0,15}$/.test(extension)) {
|
|
63
|
+
throw new WorktreeError(
|
|
64
|
+
'UNSAFE_FILE_EXTENSION',
|
|
65
|
+
'The backend returned an unsafe checkout file extension.',
|
|
66
|
+
{ file_extension: backendExtension }
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return extension.toLowerCase();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function safeIdSegment(value) {
|
|
73
|
+
const raw = String(value == null ? '' : value).trim();
|
|
74
|
+
if (!raw) throw new WorktreeError('INVALID_RESOURCE_ID', 'A non-empty resource id is required.');
|
|
75
|
+
let clean = raw.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^\.+/, '').slice(0, 80);
|
|
76
|
+
if (!clean) clean = 'resource';
|
|
77
|
+
if (clean !== raw) {
|
|
78
|
+
const suffix = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 10);
|
|
79
|
+
clean = `${clean.slice(0, 68)}-${suffix}`;
|
|
80
|
+
}
|
|
81
|
+
return clean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function entryKey(resourceType, resourceId) {
|
|
85
|
+
return `${canonicalResourceType(resourceType)}:${String(resourceId)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resourceFileName(resourceType, resourceId, extension) {
|
|
89
|
+
const canonical = canonicalResourceType(resourceType);
|
|
90
|
+
return `${RESOURCE_TYPES[canonical].prefix}_${safeIdSegment(resourceId)}${extension}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
RESOURCE_TYPES,
|
|
95
|
+
TYPE_ALIASES,
|
|
96
|
+
CONTENT_EXTENSIONS,
|
|
97
|
+
canonicalResourceType,
|
|
98
|
+
mediaType,
|
|
99
|
+
normalizeExtension,
|
|
100
|
+
safeIdSegment,
|
|
101
|
+
entryKey,
|
|
102
|
+
resourceFileName,
|
|
103
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const parse5 = require('parse5');
|
|
5
|
+
const { WorktreeError } = require('./errors');
|
|
6
|
+
const { mediaType } = require('./types');
|
|
7
|
+
|
|
8
|
+
function validateContentFile(filePath, contentType) {
|
|
9
|
+
if (!fs.existsSync(filePath)) {
|
|
10
|
+
throw new WorktreeError('LOCAL_CONTENT_MISSING', `Checked-out content is missing: ${filePath}`);
|
|
11
|
+
}
|
|
12
|
+
if (!['text/html', 'application/xhtml+xml'].includes(mediaType(contentType))) return [];
|
|
13
|
+
const charsetMatch = String(contentType || '').match(/;\s*charset\s*=\s*[']?([^;'\s]+)/i);
|
|
14
|
+
const charset = charsetMatch ? charsetMatch[1] : 'utf-8';
|
|
15
|
+
let source;
|
|
16
|
+
try {
|
|
17
|
+
source = new TextDecoder(charset, { fatal: true }).decode(fs.readFileSync(filePath));
|
|
18
|
+
} catch (error) {
|
|
19
|
+
throw new WorktreeError('LOCAL_CONTENT_ENCODING_INVALID',
|
|
20
|
+
`Unable to validate HTML using charset ${charset}: ${error.message}`);
|
|
21
|
+
}
|
|
22
|
+
const errors = [];
|
|
23
|
+
parse5.parse(source, {
|
|
24
|
+
onParseError(error) {
|
|
25
|
+
if (error.code === 'missing-doctype') return;
|
|
26
|
+
errors.push({ code: error.code, line: error.startLine || null, column: error.startCol || null });
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
if (errors.length) {
|
|
30
|
+
throw new WorktreeError('LOCAL_CONTENT_INVALID', 'HTML structure check failed before commit.', {
|
|
31
|
+
errors: errors.slice(0, 20),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return errors;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { validateContentFile };
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: draftgo-pull
|
|
3
|
-
description: Pull all or selected DraftGo resources from the connected server into local .draftgo indexes and referenced files before inspection or modification.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# DraftGo 拉取
|
|
7
|
-
|
|
8
|
-
使用 `draftgo pull`;内部 Python 脚本由 CLI 定位和执行。
|
|
9
|
-
|
|
10
|
-
## 命令
|
|
11
|
-
|
|
12
|
-
```bash
|
|
13
|
-
draftgo pull
|
|
14
|
-
draftgo pull <type> [id...]
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
不带参数时拉取全部资源。支持的类型:
|
|
18
|
-
|
|
19
|
-
`pages`、`nav`、`db_meta`、`aihub`、`system_config`、`docs`、`doc_categories`、`custom_scripts`、`roles`、`users`。
|
|
20
|
-
|
|
21
|
-
## 执行
|
|
22
|
-
|
|
23
|
-
1. 在本地资源缺失、过期、损坏,或用户明确要求同步云端版本时运行 pull。
|
|
24
|
-
2. 只拉取当前工作需要的类型或 id;需要建立完整项目上下文时运行无参数的 `draftgo pull`。
|
|
25
|
-
3. 拉取后重新读取对应 `index.json` 以及其中 `html_file`、`content_file`、`code_file` 指向的文件。
|
|
26
|
-
4. 修改自定义服务前,确认 `code_file`、`go_mod`、`go_sum` 已同步,并读取 `../references/custom-services.md`。
|
|
27
|
-
5. 运行 `draftgo map` 确认跨资源入口或依赖已更新。
|
|
28
|
-
|
|
29
|
-
## 失败处理
|
|
30
|
-
|
|
31
|
-
- 缺少 `.draftgo/config.json`:运行 `draftgo connect`。
|
|
32
|
-
- HTTP 401:连接令牌无效,重新运行 `draftgo connect`。
|
|
33
|
-
- 返回空列表但预期存在资源:检查令牌是否具备对应读取权限,不把空结果直接当成资源不存在。
|