draftgo-cli 3.0.48 → 3.0.51
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 +16 -2
- package/package.json +5 -4
- package/resources/skill/SKILL.md +11 -2
- package/resources/skill/manifest.json +1 -1
- package/resources/skill/references/checkout.md +8 -1
- package/resources/skill/references/custom-services.md +1 -0
- package/src/cli.js +34 -9
- package/src/commandRegistry.js +8 -1
- package/src/commands/api.js +102 -0
- package/src/commands/autoPush.js +1 -1
- package/src/commands/check.js +79 -2
- package/src/commands/checkout.js +3 -0
- package/src/commands/clean.js +46 -0
- package/src/commands/commit.js +34 -5
- package/src/commands/conflict.js +5 -3
- package/src/commands/conflicts.js +2 -1
- package/src/commands/context.js +2 -2
- package/src/commands/customService.js +81 -0
- package/src/commands/deploy.js +1 -1
- package/src/commands/diff.js +3 -0
- package/src/commands/help.js +37 -6
- package/src/commands/map.js +31 -22
- package/src/commands/mcp.js +26 -3
- package/src/commands/reconcile.js +34 -0
- package/src/commands/task.js +413 -0
- package/src/commands/verifyUi.js +96 -12
- package/src/context/index.js +28 -12
- package/src/customServices.js +246 -0
- package/src/mcp/client.js +52 -19
- package/src/projectMap.js +7 -2
- package/src/runtimeFiles.js +44 -0
- package/src/workspaceHealth.js +33 -0
- package/src/worktree/index.js +272 -59
- package/src/worktree/status.js +122 -0
package/src/context/index.js
CHANGED
|
@@ -15,6 +15,7 @@ const LONG_CONTENT_RESOURCE_TYPES = Object.freeze([
|
|
|
15
15
|
'pages',
|
|
16
16
|
'navigations',
|
|
17
17
|
'docs/articles',
|
|
18
|
+
'custom_services',
|
|
18
19
|
]);
|
|
19
20
|
const DB_META_LIST_PATH = '/api/db-meta';
|
|
20
21
|
const DB_META_PAGE_SIZE = 100;
|
|
@@ -292,6 +293,7 @@ function rawQuery(session, expectedName, args, server, options = {}) {
|
|
|
292
293
|
tool,
|
|
293
294
|
canonical_tool: expectedName,
|
|
294
295
|
arguments: args,
|
|
296
|
+
fetched_at: new Date().toISOString(),
|
|
295
297
|
},
|
|
296
298
|
result,
|
|
297
299
|
}));
|
|
@@ -600,28 +602,41 @@ async function collectContext(projectDir, taskValue, options = {}) {
|
|
|
600
602
|
}
|
|
601
603
|
|
|
602
604
|
return {
|
|
603
|
-
schema_version: '
|
|
605
|
+
schema_version: '2.0',
|
|
604
606
|
task,
|
|
605
|
-
|
|
607
|
+
bundled_references: {
|
|
606
608
|
source: 'draftgo-cli',
|
|
607
609
|
version: pkg.version,
|
|
608
610
|
installed_skill_version: installedSkillVersion,
|
|
609
611
|
synchronized: installedSkillVersion == null ? null : installedSkillVersion === pkg.version,
|
|
612
|
+
sections: references.map((entry) => ({
|
|
613
|
+
...entry,
|
|
614
|
+
source: { ...entry.source, source: 'cli_bundle', version: pkg.version },
|
|
615
|
+
})),
|
|
610
616
|
},
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
api,
|
|
620
|
-
data_structures: dataStructures,
|
|
617
|
+
live_project: project,
|
|
618
|
+
live_resources: resources,
|
|
619
|
+
live_apis: api,
|
|
620
|
+
live_db_meta: dataStructures,
|
|
621
|
+
live_session: {
|
|
622
|
+
server: config.server,
|
|
623
|
+
protocol_version: session.client.protocolVersion,
|
|
624
|
+
fetched_at: new Date().toISOString(),
|
|
621
625
|
},
|
|
622
626
|
};
|
|
623
627
|
}
|
|
624
628
|
|
|
629
|
+
function compactContext(value) {
|
|
630
|
+
if (Array.isArray(value)) return value.map(compactContext);
|
|
631
|
+
if (!value || typeof value !== 'object') return value;
|
|
632
|
+
if (value.source && Object.prototype.hasOwnProperty.call(value, 'result')) {
|
|
633
|
+
return { source: compactContext(value.source), value: structuredValueFromRaw(value.result) };
|
|
634
|
+
}
|
|
635
|
+
const result = {};
|
|
636
|
+
for (const [key, child] of Object.entries(value)) result[key] = compactContext(child);
|
|
637
|
+
return result;
|
|
638
|
+
}
|
|
639
|
+
|
|
625
640
|
function readInstalledSkillVersion(projectDir) {
|
|
626
641
|
return readInstalledVersion(projectDir);
|
|
627
642
|
}
|
|
@@ -643,4 +658,5 @@ module.exports = {
|
|
|
643
658
|
collectDataStructures,
|
|
644
659
|
readInstalledSkillVersion,
|
|
645
660
|
collectContext,
|
|
661
|
+
compactContext,
|
|
646
662
|
};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
const AdmZip = require('adm-zip');
|
|
8
|
+
const { loadProjectConfig } = require('./projectConfig');
|
|
9
|
+
const { WorktreeError } = require('./worktree/errors');
|
|
10
|
+
|
|
11
|
+
const FILES = ['service.go', 'go.mod', 'go.sum', 'service.json'];
|
|
12
|
+
function root(projectDir) { return path.join(projectDir, '.draftgo', 'worktree', 'custom-services'); }
|
|
13
|
+
function baseRoot(projectDir) { return path.join(projectDir, '.draftgo', 'worktree', '.base', 'custom-services'); }
|
|
14
|
+
function conflictRoot(projectDir, id) { return path.join(projectDir, '.draftgo', 'conflicts', 'custom-services', safe(id)); }
|
|
15
|
+
function manifestPath(projectDir) { return path.join(root(projectDir), 'manifest.json'); }
|
|
16
|
+
function safe(value) { return String(value).replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^\.+/, '').slice(0, 80) || 'service'; }
|
|
17
|
+
function sha256(value) { return crypto.createHash('sha256').update(value).digest('hex'); }
|
|
18
|
+
function relative(projectDir, file) { return path.relative(projectDir, file).replace(/\\/g, '/'); }
|
|
19
|
+
function loadManifest(projectDir) {
|
|
20
|
+
const file = manifestPath(projectDir);
|
|
21
|
+
if (!fs.existsSync(file)) return { schema_version: 1, entries: {} };
|
|
22
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
23
|
+
if (!value || value.schema_version !== 1 || !value.entries || Array.isArray(value.entries)) throw new WorktreeError('INVALID_WORKTREE_MANIFEST', 'Invalid custom-service manifest.');
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
function atomicWrite(file, value) {
|
|
27
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
28
|
+
const temporary = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
29
|
+
fs.writeFileSync(temporary, value); fs.renameSync(temporary, file);
|
|
30
|
+
}
|
|
31
|
+
function saveManifest(projectDir, value) { value.updated_at = new Date().toISOString(); atomicWrite(manifestPath(projectDir), `${JSON.stringify(value, null, 2)}\n`); }
|
|
32
|
+
function entryKey(id) { return String(id); }
|
|
33
|
+
function serviceDir(projectDir, id, slug) { return path.join(root(projectDir), `${safe(slug || id)}-${safe(id)}`); }
|
|
34
|
+
function serviceBaseDir(projectDir, id) { return path.join(baseRoot(projectDir), safe(id)); }
|
|
35
|
+
function parseEnvelope(text, response) {
|
|
36
|
+
let payload = {};
|
|
37
|
+
try { payload = text ? JSON.parse(text) : {}; } catch { payload = { message: text }; }
|
|
38
|
+
if (!response.ok || (payload.code && Number(payload.code) >= 400)) {
|
|
39
|
+
const details = payload.error?.details || payload.details || payload;
|
|
40
|
+
const error = new WorktreeError(payload.error?.code || payload.code || `HTTP_${response.status}`, payload.error?.message || payload.message || `DraftGo HTTP ${response.status}`, details);
|
|
41
|
+
error.status = response.status; throw error;
|
|
42
|
+
}
|
|
43
|
+
return payload.data === undefined ? payload : payload.data;
|
|
44
|
+
}
|
|
45
|
+
async function jsonRequest(config, method, endpoint, body, options = {}) {
|
|
46
|
+
const response = await (options.fetch || fetch)(`${config.server.replace(/\/+$/, '')}/api/${endpoint.replace(/^\/+/, '')}`, {
|
|
47
|
+
method, headers: { Authorization: `Bearer ${config.token || config.sat}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
|
48
|
+
body: body === undefined ? undefined : JSON.stringify(body), signal: options.signal,
|
|
49
|
+
});
|
|
50
|
+
return parseEnvelope(await response.text(), response);
|
|
51
|
+
}
|
|
52
|
+
async function metadata(config, id, options = {}) { return jsonRequest(config, 'GET', `content/custom-services/${encodeURIComponent(id)}/checkout`, undefined, options); }
|
|
53
|
+
async function download(config, metadataValue, options = {}) {
|
|
54
|
+
const url = new URL(metadataValue.download_url, `${config.server.replace(/\/+$/, '')}/`).toString();
|
|
55
|
+
const response = await (options.fetch || fetch)(url, { headers: { Authorization: `Bearer ${config.token || config.sat}` }, signal: options.signal });
|
|
56
|
+
if (!response.ok) parseEnvelope(await response.text(), response);
|
|
57
|
+
const archive = Buffer.from(await response.arrayBuffer());
|
|
58
|
+
if (sha256(archive) !== metadataValue.content_hash) throw new WorktreeError('HASH_MISMATCH', 'Downloaded custom-service archive hash mismatch.');
|
|
59
|
+
return archive;
|
|
60
|
+
}
|
|
61
|
+
function archiveFiles(archive) {
|
|
62
|
+
const zip = new AdmZip(archive); const result = {};
|
|
63
|
+
for (const filename of FILES) {
|
|
64
|
+
const entry = zip.getEntry(filename);
|
|
65
|
+
if (!entry || entry.isDirectory) throw new WorktreeError('INVALID_SERVICE_ARCHIVE', `Custom-service archive is missing ${filename}.`);
|
|
66
|
+
result[filename] = entry.getData();
|
|
67
|
+
}
|
|
68
|
+
const unexpected = zip.getEntries().filter((entry) => !entry.isDirectory && !FILES.includes(entry.entryName));
|
|
69
|
+
if (unexpected.length) throw new WorktreeError('INVALID_SERVICE_ARCHIVE', `Unexpected archive entry ${unexpected[0].entryName}.`);
|
|
70
|
+
JSON.parse(result['service.json'].toString('utf8'));
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
function writeFiles(directory, files) { fs.mkdirSync(directory, { recursive: true }); for (const filename of FILES) atomicWrite(path.join(directory, filename), files[filename]); }
|
|
74
|
+
function readFiles(directory) {
|
|
75
|
+
const result = {};
|
|
76
|
+
for (const filename of FILES) {
|
|
77
|
+
const file = path.join(directory, filename);
|
|
78
|
+
if (!fs.existsSync(file)) throw new WorktreeError('SERVICE_FILE_MISSING', `Custom-service worktree is missing ${filename}.`);
|
|
79
|
+
result[filename] = fs.readFileSync(file);
|
|
80
|
+
}
|
|
81
|
+
try { JSON.parse(result['service.json'].toString('utf8')); } catch (error) { throw new WorktreeError('INVALID_SERVICE_METADATA', `service.json is invalid: ${error.message}`); }
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
function createArchive(files) {
|
|
85
|
+
const zip = new AdmZip();
|
|
86
|
+
for (const filename of FILES) zip.addFile(filename, files[filename]);
|
|
87
|
+
return zip.toBuffer();
|
|
88
|
+
}
|
|
89
|
+
function fileHashes(files) { return Object.fromEntries(FILES.map((filename) => [filename, sha256(files[filename])])); }
|
|
90
|
+
function checkoutHash(files) { return sha256(createArchive(files)); }
|
|
91
|
+
function remoteFileHashes(remote) {
|
|
92
|
+
return { 'service.go': remote.source_hash, 'go.mod': remote.go_mod_hash, 'go.sum': remote.go_sum_hash, 'service.json': remote.metadata_hash };
|
|
93
|
+
}
|
|
94
|
+
function filesMatchRemote(files, remote) {
|
|
95
|
+
const local = fileHashes(files); const expected = remoteFileHashes(remote);
|
|
96
|
+
return FILES.every((filename) => expected[filename] && local[filename] === expected[filename]);
|
|
97
|
+
}
|
|
98
|
+
function localState(entry) {
|
|
99
|
+
const local = readFiles(entry.local_dir); const base = readFiles(entry.base_dir);
|
|
100
|
+
const localHashes = fileHashes(local); const baseHashes = fileHashes(base);
|
|
101
|
+
const baseValid = FILES.every((filename) => baseHashes[filename] === entry.file_hashes[filename]);
|
|
102
|
+
return { local, base, local_hashes: localHashes, base_hashes: baseHashes, base_valid: baseValid,
|
|
103
|
+
modified: FILES.some((filename) => localHashes[filename] !== baseHashes[filename]) };
|
|
104
|
+
}
|
|
105
|
+
async function checkout(projectDir, ids, options = {}) {
|
|
106
|
+
const config = options.config || loadProjectConfig(projectDir); const manifest = loadManifest(projectDir); const results = [];
|
|
107
|
+
for (const id of ids) {
|
|
108
|
+
const remote = await metadata(config, id, options); const key = entryKey(id); const old = manifest.entries[key];
|
|
109
|
+
if (old && fs.existsSync(old.local_dir) && !options.force && localState(old).modified) {
|
|
110
|
+
throw new WorktreeError('LOCAL_CHANGES_PRESENT', `Refusing to replace locally modified custom service ${id}; commit it or use checkout --force.`);
|
|
111
|
+
}
|
|
112
|
+
const archive = await download(config, remote, options); const files = archiveFiles(archive);
|
|
113
|
+
const service = JSON.parse(files['service.json'].toString('utf8')); const localDir = old?.local_dir || serviceDir(projectDir, id, service.slug); const baseDir = serviceBaseDir(projectDir, id);
|
|
114
|
+
writeFiles(localDir, files); writeFiles(baseDir, files);
|
|
115
|
+
const entry = { server: config.server, resource_type: 'custom_services', resource_id: String(id), title: remote.title,
|
|
116
|
+
slug: service.slug, local_dir: localDir, base_dir: baseDir, local_path: relative(projectDir, localDir), base_path: relative(projectDir, baseDir),
|
|
117
|
+
content_type: remote.content_type, base_revision: remote.base_revision, base_etag: remote.etag,
|
|
118
|
+
base_hash: remote.content_hash, file_hashes: fileHashes(files), checkout_source: remote.checkout_source,
|
|
119
|
+
checked_out_at: new Date().toISOString(), updated_at: remote.updated_at, updated_by: remote.updated_by };
|
|
120
|
+
manifest.entries[key] = entry; saveManifest(projectDir, manifest); results.push(entry);
|
|
121
|
+
}
|
|
122
|
+
return results;
|
|
123
|
+
}
|
|
124
|
+
function getEntry(projectDir, id) { return loadManifest(projectDir).entries[entryKey(id)] || null; }
|
|
125
|
+
function diffFile(baseFile, localFile, filename) {
|
|
126
|
+
if (fs.readFileSync(baseFile).equals(fs.readFileSync(localFile))) return '';
|
|
127
|
+
const result = spawnSync('git', ['diff', '--no-index', '--', baseFile, localFile], { encoding: 'utf8', windowsHide: true });
|
|
128
|
+
if (result.error || ![0, 1].includes(result.status)) return `${filename}: changed\n`;
|
|
129
|
+
return String(result.stdout || '').replaceAll(baseFile, `a/${filename}`).replaceAll(localFile, `b/${filename}`);
|
|
130
|
+
}
|
|
131
|
+
function diff(projectDir, id) {
|
|
132
|
+
const entry = getEntry(projectDir, id); if (!entry) throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `Custom service ${id} is not checked out.`);
|
|
133
|
+
const state = localState(entry); const differences = FILES.map((filename) => diffFile(path.join(entry.base_dir, filename), path.join(entry.local_dir, filename), filename)).filter(Boolean);
|
|
134
|
+
return { changed: differences.length > 0, entry, files: FILES.map((filename) => ({ filename, changed: state.local_hashes[filename] !== state.base_hashes[filename] })), output: differences.join('\n') };
|
|
135
|
+
}
|
|
136
|
+
async function writeConflict(projectDir, entry, remote, archive) {
|
|
137
|
+
const directory = conflictRoot(projectDir, entry.resource_id); const baseDir = path.join(directory, 'base'); const localDir = path.join(directory, 'local'); const remoteDir = path.join(directory, 'remote');
|
|
138
|
+
writeFiles(baseDir, readFiles(entry.base_dir)); writeFiles(localDir, readFiles(entry.local_dir)); writeFiles(remoteDir, archiveFiles(archive));
|
|
139
|
+
const record = { schema_version: 1, status: 'unresolved', code: 'RESOURCE_VERSION_CONFLICT', resource_type: 'custom_services', resource_id: entry.resource_id,
|
|
140
|
+
base_path: relative(projectDir, baseDir), local_path: relative(projectDir, localDir), remote_path: relative(projectDir, remoteDir), worktree_local_path: entry.local_path,
|
|
141
|
+
expected_revision: entry.base_revision, actual_revision: remote.base_revision, expected_hash: entry.base_hash, actual_hash: remote.content_hash,
|
|
142
|
+
actual_etag: remote.etag, actual_updated_at: remote.updated_at, created_at: new Date().toISOString() };
|
|
143
|
+
atomicWrite(path.join(directory, 'conflict.json'), `${JSON.stringify(record, null, 2)}\n`); return record;
|
|
144
|
+
}
|
|
145
|
+
function serviceConflicts(projectDir, options = {}) {
|
|
146
|
+
const directory = path.join(projectDir, '.draftgo', 'conflicts', 'custom-services'); if (!fs.existsSync(directory)) return [];
|
|
147
|
+
return fs.readdirSync(directory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).flatMap((entry) => {
|
|
148
|
+
const file = path.join(directory, entry.name, 'conflict.json'); if (!fs.existsSync(file)) return [];
|
|
149
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8')); return options.all || record.status === 'unresolved' ? [{ ...record, manifest_path: relative(projectDir, file) }] : [];
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function showConflict(projectDir, id) {
|
|
153
|
+
const record = serviceConflicts(projectDir, { all: true }).find((entry) => String(entry.resource_id) === String(id));
|
|
154
|
+
if (!record) throw new WorktreeError('CONFLICT_NOT_FOUND', `No custom-service conflict found for ${id}.`); return record;
|
|
155
|
+
}
|
|
156
|
+
function resolveConflict(projectDir, id) {
|
|
157
|
+
const record = showConflict(projectDir, id); if (record.status !== 'unresolved') throw new WorktreeError('CONFLICT_ALREADY_RESOLVED', `Custom-service conflict ${id} is already resolved.`);
|
|
158
|
+
const manifest = loadManifest(projectDir); const entry = manifest.entries[entryKey(id)]; if (!entry) throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `Custom service ${id} is not checked out.`);
|
|
159
|
+
const remoteDir = path.resolve(projectDir, record.remote_path); const remoteFiles = readFiles(remoteDir); writeFiles(entry.base_dir, remoteFiles);
|
|
160
|
+
entry.file_hashes = fileHashes(remoteFiles); entry.base_hash = record.actual_hash; entry.base_revision = record.actual_revision; entry.base_etag = record.actual_etag; entry.updated_at = record.actual_updated_at; entry.conflict_resolved_at = new Date().toISOString();
|
|
161
|
+
manifest.entries[entryKey(id)] = entry; saveManifest(projectDir, manifest);
|
|
162
|
+
const resolved = { ...record, status: 'resolved', resolved_at: new Date().toISOString() }; delete resolved.manifest_path;
|
|
163
|
+
atomicWrite(path.resolve(projectDir, record.manifest_path), `${JSON.stringify(resolved, null, 2)}\n`); return { ...resolved, manifest_path: record.manifest_path };
|
|
164
|
+
}
|
|
165
|
+
async function commit(projectDir, ids, options = {}) {
|
|
166
|
+
const config = options.config || loadProjectConfig(projectDir); const manifest = loadManifest(projectDir); const results = [];
|
|
167
|
+
const prepared = [];
|
|
168
|
+
for (const id of ids) {
|
|
169
|
+
const entry = manifest.entries[entryKey(id)]; if (!entry) throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `Custom service ${id} is not checked out.`);
|
|
170
|
+
if (entry.server !== config.server) throw new WorktreeError('CHECKOUT_SERVER_MISMATCH', 'Checkout belongs to a different DraftGo server.');
|
|
171
|
+
const state = localState(entry); if (!state.base_valid) throw new WorktreeError('BASE_HASH_MISMATCH', `Custom service ${id} base does not match its manifest.`);
|
|
172
|
+
if (!state.modified) { prepared.push({ id, entry, state, unchanged: true }); continue; }
|
|
173
|
+
const current = await metadata(config, id, options);
|
|
174
|
+
if (current.content_hash !== entry.base_hash || String(current.base_revision) !== String(entry.base_revision)) {
|
|
175
|
+
const remoteArchive = await download(config, current, options); const conflict = await writeConflict(projectDir, entry, current, remoteArchive);
|
|
176
|
+
throw new WorktreeError('RESOURCE_VERSION_CONFLICT', `Custom service ${id} changed remotely; conflict materials were preserved.`, conflict);
|
|
177
|
+
}
|
|
178
|
+
prepared.push({ id, entry, state, current, archive: createArchive(state.local) });
|
|
179
|
+
}
|
|
180
|
+
for (const item of prepared) {
|
|
181
|
+
const { id, entry, current, archive } = item;
|
|
182
|
+
if (item.unchanged) { results.push({ resource_type: 'custom_services', resource_id: String(id), status: 'unchanged' }); continue; }
|
|
183
|
+
const url = new URL(current.commit_url, `${config.server.replace(/\/+$/, '')}/`).toString();
|
|
184
|
+
const response = await (options.fetch || fetch)(url, { method: 'PUT', headers: { Authorization: `Bearer ${config.token || config.sat}`, 'Content-Type': current.content_type,
|
|
185
|
+
'Content-Length': String(archive.length), 'X-Content-SHA256': sha256(archive), 'If-Match': entry.base_etag }, body: archive, signal: options.signal });
|
|
186
|
+
let committed;
|
|
187
|
+
try { committed = parseEnvelope(await response.text(), response); } catch (error) {
|
|
188
|
+
if ([409, 412].includes(error.status)) { const latest = await metadata(config, id, options); const remoteArchive = await download(config, latest, options); error.details = await writeConflict(projectDir, entry, latest, remoteArchive); }
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
const canonicalArchive = await download(config, committed, options); const canonicalFiles = archiveFiles(canonicalArchive);
|
|
192
|
+
writeFiles(entry.local_dir, canonicalFiles); writeFiles(entry.base_dir, canonicalFiles);
|
|
193
|
+
entry.file_hashes = fileHashes(canonicalFiles); entry.base_hash = committed.content_hash;
|
|
194
|
+
entry.base_revision = committed.base_revision; entry.base_etag = committed.etag; entry.updated_at = committed.updated_at; entry.updated_by = committed.updated_by; entry.committed_at = new Date().toISOString();
|
|
195
|
+
manifest.entries[entryKey(id)] = entry; saveManifest(projectDir, manifest);
|
|
196
|
+
results.push({ resource_type: 'custom_services', resource_id: String(id), status: 'committed', revision: committed.base_revision, hash: committed.content_hash });
|
|
197
|
+
}
|
|
198
|
+
return results;
|
|
199
|
+
}
|
|
200
|
+
async function inspectRemote(projectDir, options = {}) {
|
|
201
|
+
const config = options.config || loadProjectConfig(projectDir);
|
|
202
|
+
const requested = options.ids ? new Set(options.ids.map(String)) : null;
|
|
203
|
+
const entries = Object.values(loadManifest(projectDir).entries)
|
|
204
|
+
.filter((entry) => !requested || requested.has(String(entry.resource_id)));
|
|
205
|
+
const result = [];
|
|
206
|
+
for (const entry of entries) {
|
|
207
|
+
const state = localState(entry); const remote = await metadata(config, entry.resource_id, options); const remoteMatches = remote.content_hash === entry.base_hash && String(remote.base_revision) === String(entry.base_revision);
|
|
208
|
+
const localArchiveHash = checkoutHash(state.local); let status;
|
|
209
|
+
if (remoteMatches) status = state.modified ? 'local_modified' : 'clean';
|
|
210
|
+
else if (!state.modified) status = 'remote_changed';
|
|
211
|
+
else if (filesMatchRemote(state.local, remote)) status = 'committed_unrecorded';
|
|
212
|
+
else status = 'diverged';
|
|
213
|
+
result.push({ ...entry, state: status, remote_hash: remote.content_hash, remote_revision: remote.base_revision, remote_etag: remote.etag, local_hash: localArchiveHash });
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
async function validate(projectDir, id, options = {}) {
|
|
218
|
+
const config = options.config || loadProjectConfig(projectDir); await commit(projectDir, [id], options);
|
|
219
|
+
const result = await jsonRequest(config, 'POST', `scripts/${encodeURIComponent(id)}/draft/validate`, {}, options);
|
|
220
|
+
await checkout(projectDir, [id], { ...options, config, force: true }); return result;
|
|
221
|
+
}
|
|
222
|
+
function parseHandler(handler) {
|
|
223
|
+
const value = String(handler || '').trim(); if (!value) return undefined;
|
|
224
|
+
const parts = value.split(':');
|
|
225
|
+
if (parts[0] === 'route' && parts.length >= 3) return { kind: 'route', method: parts[1], path: parts.slice(2).join(':') };
|
|
226
|
+
if (parts[0] === 'event') return { kind: 'event', event: parts.slice(1).join(':') };
|
|
227
|
+
if (parts[0] === 'scheduled') return { kind: 'scheduled', name: parts.slice(1).join(':') };
|
|
228
|
+
return { kind: 'manual', name: value };
|
|
229
|
+
}
|
|
230
|
+
async function test(projectDir, id, input = {}, options = {}) {
|
|
231
|
+
const config = options.config || loadProjectConfig(projectDir); await validate(projectDir, id, options);
|
|
232
|
+
return jsonRequest(config, 'POST', `scripts/${encodeURIComponent(id)}/draft/execute`, {
|
|
233
|
+
input, selector: parseHandler(options.handler), headers: options.headers, user: options.user,
|
|
234
|
+
test_write: Boolean(options.testWrite), side_effect_policy: options.sideEffectPolicy || 'deny',
|
|
235
|
+
}, options);
|
|
236
|
+
}
|
|
237
|
+
async function publish(projectDir, ids, options = {}) {
|
|
238
|
+
const config = options.config || loadProjectConfig(projectDir); const results = [];
|
|
239
|
+
for (const id of ids) {
|
|
240
|
+
await validate(projectDir, id, options); results.push(await jsonRequest(config, 'POST', `scripts/${encodeURIComponent(id)}/draft/publish`, {}, options));
|
|
241
|
+
await checkout(projectDir, [id], { ...options, config, force: true });
|
|
242
|
+
}
|
|
243
|
+
return results;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = { FILES, checkout, commit, diff, validate, test, publish, inspectRemote, loadManifest, getEntry, metadata, archiveFiles, createArchive, serviceConflicts, showConflict, resolveConflict, parseHandler };
|
package/src/mcp/client.js
CHANGED
|
@@ -225,16 +225,30 @@ class DraftGoMcpClient {
|
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
async initialize(options = {}) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
228
|
+
if (typeof options.onStage === 'function') options.onStage('initialize', 'started');
|
|
229
|
+
let result;
|
|
230
|
+
try {
|
|
231
|
+
result = await this.request('initialize', {
|
|
232
|
+
protocolVersion: options.protocolVersion || this.protocolVersion,
|
|
233
|
+
capabilities: options.capabilities || {},
|
|
234
|
+
clientInfo: options.clientInfo || {
|
|
235
|
+
name: 'draftgo-cli',
|
|
236
|
+
version: pkg.version,
|
|
237
|
+
},
|
|
238
|
+
}, options);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (typeof options.onStage === 'function') options.onStage('initialize', 'failed');
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
if (typeof options.onStage === 'function') options.onStage('initialize', 'succeeded', result);
|
|
236
244
|
if (result && result.protocolVersion) this.protocolVersion = result.protocolVersion;
|
|
237
|
-
|
|
245
|
+
try {
|
|
246
|
+
await this.notify('notifications/initialized', undefined, options);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (typeof options.onStage === 'function') options.onStage('initialized', 'failed');
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
if (typeof options.onStage === 'function') options.onStage('initialized', 'succeeded');
|
|
238
252
|
return result;
|
|
239
253
|
}
|
|
240
254
|
|
|
@@ -279,7 +293,15 @@ class DraftGoMcpClient {
|
|
|
279
293
|
|
|
280
294
|
async testConnection(options = {}) {
|
|
281
295
|
const initialized = await this.initialize(options);
|
|
282
|
-
|
|
296
|
+
if (typeof options.onStage === 'function') options.onStage('tools/list', 'started');
|
|
297
|
+
let tools;
|
|
298
|
+
try {
|
|
299
|
+
tools = await this.listAllTools(options);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (typeof options.onStage === 'function') options.onStage('tools/list', 'failed');
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
if (typeof options.onStage === 'function') options.onStage('tools/list', 'succeeded', { count: tools.length });
|
|
283
305
|
const requiredTools = options.requiredTools || REQUIRED_DRAFTGO_TOOLS;
|
|
284
306
|
const missing = requiredTools.filter((expected) => !tools.some((tool) =>
|
|
285
307
|
tool && typeof tool.name === 'string' && toolMatches(tool.name, expected)));
|
|
@@ -325,21 +347,32 @@ class DraftGoMcpClient {
|
|
|
325
347
|
},
|
|
326
348
|
];
|
|
327
349
|
const runPlan = (plan, startIndex = 0) => allWithAbort(plan.map((entry, offset) =>
|
|
328
|
-
async (queryOptions) =>
|
|
350
|
+
async (queryOptions) => {
|
|
351
|
+
if (typeof options.onStage === 'function') options.onStage(`tools/call:${entry.label}`, 'started');
|
|
352
|
+
let result;
|
|
353
|
+
try {
|
|
354
|
+
result = await this.toolsCall(
|
|
355
|
+
entry.tool.name,
|
|
356
|
+
options.toolArguments && startIndex + offset === 0
|
|
357
|
+
? options.toolArguments
|
|
358
|
+
: entry.args,
|
|
359
|
+
queryOptions,
|
|
360
|
+
);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (typeof options.onStage === 'function') options.onStage(`tools/call:${entry.label}`, 'failed');
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
if (typeof options.onStage === 'function') options.onStage(`tools/call:${entry.label}`, 'succeeded');
|
|
366
|
+
return ({
|
|
329
367
|
label: entry.label,
|
|
330
368
|
canonical: entry.canonical,
|
|
331
369
|
name: entry.tool.name,
|
|
332
370
|
arguments: options.toolArguments && startIndex + offset === 0
|
|
333
371
|
? options.toolArguments
|
|
334
372
|
: entry.args,
|
|
335
|
-
result
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
? options.toolArguments
|
|
339
|
-
: entry.args,
|
|
340
|
-
queryOptions,
|
|
341
|
-
),
|
|
342
|
-
})), options);
|
|
373
|
+
result,
|
|
374
|
+
});
|
|
375
|
+
}), options);
|
|
343
376
|
const tested = await runPlan(defaultPlan);
|
|
344
377
|
if (!hasDiagnosticOverrides) {
|
|
345
378
|
let discovery = tested.find((entry) => entry.label === 'api:db_meta');
|
package/src/projectMap.js
CHANGED
|
@@ -148,8 +148,13 @@ function analyzeProject(projectDir) {
|
|
|
148
148
|
}
|
|
149
149
|
if (!fs.existsSync(base)) {
|
|
150
150
|
errors.push(`${entry.resource_type} ${entry.resource_id}: checkout base is missing (${entry.base_path}).`);
|
|
151
|
-
} else
|
|
152
|
-
|
|
151
|
+
} else {
|
|
152
|
+
const actualBaseHash = sha256File(base);
|
|
153
|
+
if (actualBaseHash !== entry.base_hash) {
|
|
154
|
+
errors.push(`${entry.resource_type} ${entry.resource_id}: checkout base hash does not match the manifest `
|
|
155
|
+
+ `(manifest=${entry.base_hash}, base=${actualBaseHash}). `
|
|
156
|
+
+ `Run \`draftgo check --remote\` to classify the mismatch before replacing files.`);
|
|
157
|
+
}
|
|
153
158
|
}
|
|
154
159
|
|
|
155
160
|
const type = mediaType(entry.content_type);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const SCHEMA_VERSION = 1;
|
|
7
|
+
const RETRYABLE_RENAME_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
8
|
+
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
9
|
+
function draftgoRoot(projectDir) { return path.resolve(projectDir, '.draftgo'); }
|
|
10
|
+
function manifestPath(projectDir) { return path.join(draftgoRoot(projectDir), 'runtime-manifest.json'); }
|
|
11
|
+
function inside(root, target) { const relation = path.relative(root, target); return relation && !relation.startsWith('..') && !path.isAbsolute(relation); }
|
|
12
|
+
function load(projectDir) {
|
|
13
|
+
const file = manifestPath(projectDir);
|
|
14
|
+
if (!fs.existsSync(file)) return { schema_version: SCHEMA_VERSION, entries: [] };
|
|
15
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
16
|
+
if (!value || value.schema_version !== SCHEMA_VERSION || !Array.isArray(value.entries)) throw new Error('Invalid .draftgo/runtime-manifest.json.');
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function replaceFile(temporary, file, rename = fs.renameSync, wait = (ms) => Atomics.wait(sleepBuffer, 0, 0, ms)) {
|
|
20
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
21
|
+
try { rename(temporary, file); return; } catch (error) {
|
|
22
|
+
if (!RETRYABLE_RENAME_CODES.has(error.code) || attempt >= 5) throw error;
|
|
23
|
+
wait(20 * (attempt + 1));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function save(projectDir, value) {
|
|
28
|
+
const file = manifestPath(projectDir); fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
29
|
+
const temporary = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
30
|
+
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`);
|
|
31
|
+
try { replaceFile(temporary, file); } catch (error) {
|
|
32
|
+
try { fs.rmSync(temporary, { force: true }); } catch { /* retain the original rename error */ }
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function register(projectDir, file, type, command, options = {}) {
|
|
37
|
+
const root = draftgoRoot(projectDir); const absolute = path.resolve(file);
|
|
38
|
+
if (!inside(root, absolute)) throw new Error('Managed runtime files must stay inside .draftgo.');
|
|
39
|
+
const relative = path.relative(root, absolute).replace(/\\/g, '/'); const value = load(projectDir);
|
|
40
|
+
const entry = { path: relative, type: String(type), command: String(command), created_at: new Date().toISOString(), cleanable: options.cleanable !== false };
|
|
41
|
+
value.entries = value.entries.filter((item) => item.path !== relative); value.entries.push(entry); save(projectDir, value); return entry;
|
|
42
|
+
}
|
|
43
|
+
function managedPath(projectDir, ...parts) { const file = path.join(draftgoRoot(projectDir), ...parts); if (!inside(draftgoRoot(projectDir), file)) throw new Error('Managed path escapes .draftgo.'); return file; }
|
|
44
|
+
module.exports = { SCHEMA_VERSION, draftgoRoot, manifestPath, load, save, register, managedPath, inside, replaceFile };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const runtime = require('./runtimeFiles');
|
|
6
|
+
|
|
7
|
+
const PROTECTED_PREFIXES = ['worktree/', 'conflicts/', 'Task/', 'lessons/', 'config.json', 'changelog.md', 'story.yaml', '.version', 'runtime-manifest.json'];
|
|
8
|
+
function workspaceHealth(projectDir) {
|
|
9
|
+
const root = runtime.draftgoRoot(projectDir); const registered = new Set(runtime.load(projectDir).entries.map((entry) => String(entry.path).replace(/\\/g, '/')));
|
|
10
|
+
const summary = { managed_files: 0, unknown_files: 0, temporary_files: 0, artifact_files: 0, total_bytes: 0, reclaimable_bytes: 0, warnings: [] };
|
|
11
|
+
if (!fs.existsSync(root)) return summary;
|
|
12
|
+
const visit = (directory) => {
|
|
13
|
+
for (const item of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
14
|
+
const absolute = path.join(directory, item.name); const relative = path.relative(root, absolute).replace(/\\/g, '/');
|
|
15
|
+
if (item.isDirectory()) {
|
|
16
|
+
if ((/^(.+\/)?(build|dist|node_modules|tmp-build|build-)/i.test(relative)) && directorySize(absolute) > 50 * 1024 * 1024) summary.warnings.push({ code: 'DG-WORKSPACE-LARGE-BUILD', path: relative });
|
|
17
|
+
visit(absolute); continue;
|
|
18
|
+
}
|
|
19
|
+
if (!item.isFile()) continue;
|
|
20
|
+
const size = fs.statSync(absolute).size; summary.total_bytes += size;
|
|
21
|
+
const managed = registered.has(relative) || PROTECTED_PREFIXES.some((prefix) => relative === prefix || relative.startsWith(prefix));
|
|
22
|
+
if (managed) summary.managed_files += 1; else summary.unknown_files += 1;
|
|
23
|
+
if (relative.startsWith('tmp/')) summary.temporary_files += 1;
|
|
24
|
+
if (relative.startsWith('artifacts/')) summary.artifact_files += 1;
|
|
25
|
+
if (registered.has(relative)) summary.reclaimable_bytes += size;
|
|
26
|
+
if (!managed && (/\.exe$/i.test(relative) || (/^[^/]+\.(go|json)$/i.test(relative) && !['config.json', 'runtime-manifest.json'].includes(relative)))) summary.warnings.push({ code: 'DG-WORKSPACE-UNKNOWN-FILE', path: relative, size });
|
|
27
|
+
if (relative.startsWith('artifacts/') && !registered.has(relative)) summary.warnings.push({ code: 'DG-WORKSPACE-UNREGISTERED-ARTIFACT', path: relative, size });
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
visit(root); return summary;
|
|
31
|
+
}
|
|
32
|
+
function directorySize(directory) { let total = 0; const visit = (current) => { for (const item of fs.readdirSync(current, { withFileTypes: true })) { const absolute = path.join(current, item.name); if (item.isDirectory()) visit(absolute); else if (item.isFile()) total += fs.statSync(absolute).size; if (total > 50 * 1024 * 1024) return; } }; visit(directory); return total; }
|
|
33
|
+
module.exports = { workspaceHealth };
|