draftgo-cli 1.0.4
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/LICENSE +21 -0
- package/README.md +249 -0
- package/bin/draftgo.js +9 -0
- package/package.json +70 -0
- package/resources/project-design/README.md +42 -0
- package/resources/skill/SKILL.md +62 -0
- package/resources/skill/init/SKILL.md +41 -0
- package/resources/skill/manifest.json +35 -0
- package/resources/skill/references/ai.md +41 -0
- package/resources/skill/references/app-api.md +97 -0
- package/resources/skill/references/architecture.md +13 -0
- package/resources/skill/references/chat-sdk.md +205 -0
- package/resources/skill/references/checkout.md +140 -0
- package/resources/skill/references/data.md +49 -0
- package/resources/skill/references/db-relations.md +29 -0
- package/resources/skill/references/delivery.md +33 -0
- package/resources/skill/references/development.md +41 -0
- package/resources/skill/references/diagnostics.md +50 -0
- package/resources/skill/references/frontend.md +158 -0
- package/resources/skill/references/mcp.md +110 -0
- package/resources/skill/references/methods.md +143 -0
- package/resources/skill/references/modules.md +75 -0
- package/resources/skill/references/runtime.md +109 -0
- package/resources/skill/references/services.md +32 -0
- package/src/apiContractCache.js +120 -0
- package/src/cli.js +100 -0
- package/src/commandRegistry.js +46 -0
- package/src/commands/api.js +244 -0
- package/src/commands/apiKey.js +30 -0
- package/src/commands/autoPush.js +36 -0
- package/src/commands/capabilities.js +100 -0
- package/src/commands/check.js +82 -0
- package/src/commands/checkout.js +18 -0
- package/src/commands/clean.js +72 -0
- package/src/commands/commit.js +47 -0
- package/src/commands/components.js +554 -0
- package/src/commands/conflict.js +30 -0
- package/src/commands/conflicts.js +16 -0
- package/src/commands/connect.js +91 -0
- package/src/commands/delete.js +95 -0
- package/src/commands/deploy.js +77 -0
- package/src/commands/diff.js +39 -0
- package/src/commands/group.js +37 -0
- package/src/commands/help.js +190 -0
- package/src/commands/init.js +126 -0
- package/src/commands/listTargets.js +13 -0
- package/src/commands/local.js +79 -0
- package/src/commands/map.js +395 -0
- package/src/commands/mcp.js +150 -0
- package/src/commands/reconcile.js +20 -0
- package/src/commands/role.js +31 -0
- package/src/commands/status.js +98 -0
- package/src/commands/uninstall.js +52 -0
- package/src/commands/update.js +79 -0
- package/src/commands/verify.js +188 -0
- package/src/commands/visualVerify.js +281 -0
- package/src/commands/worklog.js +117 -0
- package/src/consoleEncoding.js +34 -0
- package/src/contractCompatibility.js +65 -0
- package/src/detect.js +25 -0
- package/src/diffReport.js +106 -0
- package/src/fsx.js +67 -0
- package/src/index.js +46 -0
- package/src/localRuntime/compose.js +119 -0
- package/src/localRuntime/detect.js +77 -0
- package/src/localRuntime/index.js +211 -0
- package/src/localRuntime/mysqlClient.js +155 -0
- package/src/localRuntime/services.js +117 -0
- package/src/logger.js +37 -0
- package/src/mcp/client.js +558 -0
- package/src/mcp/hosts.js +520 -0
- package/src/mcp/parallel.js +54 -0
- package/src/mcp/protocol.js +223 -0
- package/src/mcp/stdio.js +300 -0
- package/src/mcp/tools.js +51 -0
- package/src/paths.js +32 -0
- package/src/platforms.js +110 -0
- package/src/projectConfig.js +139 -0
- package/src/projectDesign.js +19 -0
- package/src/projectHealth.js +33 -0
- package/src/projectMap.js +220 -0
- package/src/prompt.js +94 -0
- package/src/releaseInstall.js +105 -0
- package/src/runtimeFiles.js +45 -0
- package/src/skill.js +295 -0
- package/src/targets.js +43 -0
- package/src/timeout.js +18 -0
- package/src/updateCheck.js +100 -0
- package/src/worklog.js +276 -0
- package/src/worktree/backend.js +438 -0
- package/src/worktree/errors.js +28 -0
- package/src/worktree/index.js +751 -0
- package/src/worktree/inlineScripts.js +99 -0
- package/src/worktree/locks.js +52 -0
- package/src/worktree/manifest.js +89 -0
- package/src/worktree/status.js +124 -0
- package/src/worktree/streams.js +200 -0
- package/src/worktree/types.js +103 -0
- package/src/worktree/validate.js +37 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const vm = require('vm');
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
5
|
+
const parse5 = require('parse5');
|
|
6
|
+
|
|
7
|
+
const DATA_SCRIPT_TYPES = new Set([
|
|
8
|
+
'application/json',
|
|
9
|
+
'application/ld+json',
|
|
10
|
+
'importmap',
|
|
11
|
+
'speculationrules',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
function attribute(node, name) {
|
|
15
|
+
const item = (node.attrs || []).find((candidate) => candidate.name.toLowerCase() === name);
|
|
16
|
+
return item ? item.value : '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function scriptSource(node) {
|
|
20
|
+
return (node.childNodes || [])
|
|
21
|
+
.filter((child) => child.nodeName === '#text')
|
|
22
|
+
.map((child) => child.value || '')
|
|
23
|
+
.join('');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function collectInlineScripts(html) {
|
|
27
|
+
const document = parse5.parse(String(html || ''), { sourceCodeLocationInfo: true });
|
|
28
|
+
const scripts = [];
|
|
29
|
+
const visit = (node) => {
|
|
30
|
+
if (node.tagName === 'script' && !attribute(node, 'src')) {
|
|
31
|
+
const type = attribute(node, 'type').trim().toLowerCase();
|
|
32
|
+
if (!DATA_SCRIPT_TYPES.has(type) && (!type || type === 'module' || /(?:java|ecma)script/.test(type))) {
|
|
33
|
+
const location = node.sourceCodeLocation || {};
|
|
34
|
+
const startTag = location.startTag || {};
|
|
35
|
+
scripts.push({
|
|
36
|
+
index: scripts.length + 1,
|
|
37
|
+
type: type === 'module' ? 'module' : 'classic',
|
|
38
|
+
source: scriptSource(node),
|
|
39
|
+
startLine: startTag.endLine || location.startLine || 1,
|
|
40
|
+
startColumn: startTag.endCol || 1,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const child of node.childNodes || []) visit(child);
|
|
45
|
+
if (node.content) visit(node.content);
|
|
46
|
+
};
|
|
47
|
+
visit(document);
|
|
48
|
+
return scripts;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function syntaxPosition(message) {
|
|
52
|
+
const line = String(message || '').match(/(?:\[stdin\]|inline-script):(\d+)(?::(\d+))?/);
|
|
53
|
+
if (line) return { line: Number(line[1]), column: Number(line[2] || 1) };
|
|
54
|
+
const caretLines = String(message || '').split(/\r?\n/);
|
|
55
|
+
const caretIndex = caretLines.findIndex((value) => /^\s*\^/.test(value));
|
|
56
|
+
return { line: null, column: caretIndex > 0 ? caretLines[caretIndex].indexOf('^') + 1 : null };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function conciseMessage(error) {
|
|
60
|
+
const lines = String(error && (error.stderr || error.message) || error || '').split(/\r?\n/);
|
|
61
|
+
return lines.find((line) => /^SyntaxError:/.test(line))
|
|
62
|
+
|| lines.find((line) => line.trim() && !/^\s*at\s/.test(line))
|
|
63
|
+
|| 'Invalid JavaScript syntax';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateInlineScripts(html) {
|
|
67
|
+
const issues = [];
|
|
68
|
+
for (const script of collectInlineScripts(html)) {
|
|
69
|
+
if (!script.source.trim()) continue;
|
|
70
|
+
let failure = null;
|
|
71
|
+
if (script.type === 'module') {
|
|
72
|
+
const result = spawnSync(process.execPath, ['--check', '--input-type=module'], {
|
|
73
|
+
input: script.source,
|
|
74
|
+
encoding: 'utf8',
|
|
75
|
+
windowsHide: true,
|
|
76
|
+
});
|
|
77
|
+
if (result.error || result.status !== 0) failure = { message: result.stderr || result.stdout || result.error.message };
|
|
78
|
+
} else {
|
|
79
|
+
try { new vm.Script(script.source, { filename: 'inline-script' }); }
|
|
80
|
+
catch (error) { failure = error; }
|
|
81
|
+
}
|
|
82
|
+
if (!failure) continue;
|
|
83
|
+
const diagnostic = failure.stack || failure.message;
|
|
84
|
+
const position = syntaxPosition(diagnostic);
|
|
85
|
+
issues.push({
|
|
86
|
+
code: 'DG-JS-001',
|
|
87
|
+
script: script.index,
|
|
88
|
+
script_type: script.type,
|
|
89
|
+
line: position.line ? script.startLine + position.line - 1 : script.startLine,
|
|
90
|
+
column: position.column
|
|
91
|
+
? position.column + (position.line === 1 ? script.startColumn - 1 : 0)
|
|
92
|
+
: script.startColumn,
|
|
93
|
+
message: conciseMessage(failure),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return issues;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { DATA_SCRIPT_TYPES, collectInlineScripts, validateInlineScripts };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const { acquireLock, releaseLock } = require('../worklog');
|
|
7
|
+
const { WorktreeError } = require('./errors');
|
|
8
|
+
|
|
9
|
+
// Reuse owner-token locks, but never expire an active long-running transfer.
|
|
10
|
+
function lockFile(file) {
|
|
11
|
+
const lockPath = `${file}.lock`;
|
|
12
|
+
try {
|
|
13
|
+
const token = fs.readFileSync(lockPath, 'utf8').trim();
|
|
14
|
+
const pid = Number(token.split(':')[0]);
|
|
15
|
+
if (Number.isSafeInteger(pid) && pid > 0) {
|
|
16
|
+
try { process.kill(pid, 0); } catch (error) {
|
|
17
|
+
if (error.code === 'ESRCH' && fs.readFileSync(lockPath, 'utf8').trim() === token) fs.rmSync(lockPath);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
21
|
+
try { return acquireLock(file, { lockTimeoutMs: 0, lockStaleMs: Number.MAX_SAFE_INTEGER }); }
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code !== 'WORKLOG_LOCK_TIMEOUT') throw error;
|
|
24
|
+
throw new WorktreeError('WORKTREE_LOCKED', 'Another process owns this worktree resource; retry after it finishes.', { lock_path: lockPath });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function withResourceLocks(projectDir, keys, action) {
|
|
29
|
+
const locks = [];
|
|
30
|
+
try {
|
|
31
|
+
for (const key of [...new Set(keys)].sort()) {
|
|
32
|
+
const name = crypto.createHash('sha256').update(key).digest('hex');
|
|
33
|
+
locks.push(lockFile(path.join(projectDir, '.draftgo', 'worktree', '.locks', name)));
|
|
34
|
+
}
|
|
35
|
+
return await action();
|
|
36
|
+
} finally { for (const lock of locks.reverse()) releaseLock(lock); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function withManifestLock(projectDir, action) {
|
|
40
|
+
const file = path.join(projectDir, '.draftgo', 'worktree', 'manifest.json');
|
|
41
|
+
const started = Date.now();
|
|
42
|
+
let lock;
|
|
43
|
+
while (!lock) {
|
|
44
|
+
try { lock = lockFile(file); } catch (error) {
|
|
45
|
+
if (error.code !== 'WORKTREE_LOCKED' || Date.now() - started >= 10000) throw error;
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, 15));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
try { return await action(); } finally { releaseLock(lock); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { withResourceLocks, withManifestLock };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { WorktreeError } = require('./errors');
|
|
6
|
+
const { entryKey } = require('./types');
|
|
7
|
+
const { writeJsonAtomic } = require('./streams');
|
|
8
|
+
|
|
9
|
+
const { withManifestLock } = require('./locks');
|
|
10
|
+
const snapshots = new WeakMap();
|
|
11
|
+
const MANIFEST_SCHEMA_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
function manifestPath(projectDir) {
|
|
14
|
+
return path.join(projectDir, '.draftgo', 'worktree', 'manifest.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function emptyManifest() {
|
|
18
|
+
const value = { schema_version: MANIFEST_SCHEMA_VERSION, entries: {} };
|
|
19
|
+
snapshots.set(value, {});
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function loadManifest(projectDir) {
|
|
24
|
+
const file = manifestPath(projectDir);
|
|
25
|
+
if (!fs.existsSync(file)) return emptyManifest();
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw new WorktreeError('INVALID_WORKTREE_MANIFEST', `Invalid checkout manifest: ${error.message}`);
|
|
31
|
+
}
|
|
32
|
+
if (!parsed || parsed.schema_version !== MANIFEST_SCHEMA_VERSION
|
|
33
|
+
|| !parsed.entries || typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
|
|
34
|
+
throw new WorktreeError('INVALID_WORKTREE_MANIFEST', 'Unsupported or malformed checkout manifest.');
|
|
35
|
+
}
|
|
36
|
+
snapshots.set(parsed, JSON.parse(JSON.stringify(parsed.entries)));
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getEntry(manifest, resourceType, resourceId) {
|
|
41
|
+
return manifest.entries[entryKey(resourceType, resourceId)] || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function relativePath(projectDir, absolutePath) {
|
|
45
|
+
const relative = path.relative(path.resolve(projectDir), path.resolve(absolutePath));
|
|
46
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
47
|
+
throw new WorktreeError('INVALID_WORKTREE_PATH', 'Checkout paths must stay inside the project.');
|
|
48
|
+
}
|
|
49
|
+
return relative.replace(/\\/g, '/');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function absolutePath(projectDir, relative) {
|
|
53
|
+
if (!relative || typeof relative !== 'string' || path.isAbsolute(relative)) {
|
|
54
|
+
throw new WorktreeError('INVALID_WORKTREE_PATH', 'Manifest path must be project-relative.');
|
|
55
|
+
}
|
|
56
|
+
const root = path.resolve(projectDir);
|
|
57
|
+
const resolved = path.resolve(root, relative);
|
|
58
|
+
const relation = path.relative(root, resolved);
|
|
59
|
+
if (!relation || relation.startsWith('..') || path.isAbsolute(relation)) {
|
|
60
|
+
throw new WorktreeError('INVALID_WORKTREE_PATH', 'Manifest path escapes the project.');
|
|
61
|
+
}
|
|
62
|
+
return resolved;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function saveManifest(projectDir, manifest) {
|
|
66
|
+
const before = snapshots.get(manifest) || {};
|
|
67
|
+
await withManifestLock(projectDir, async () => {
|
|
68
|
+
const fresh = loadManifest(projectDir);
|
|
69
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(manifest.entries)])) {
|
|
70
|
+
if (JSON.stringify(before[key]) === JSON.stringify(manifest.entries[key])) continue;
|
|
71
|
+
if (manifest.entries[key] === undefined) delete fresh.entries[key];
|
|
72
|
+
else fresh.entries[key] = manifest.entries[key];
|
|
73
|
+
}
|
|
74
|
+
fresh.updated_at = new Date().toISOString();
|
|
75
|
+
await writeJsonAtomic(manifestPath(projectDir), fresh);
|
|
76
|
+
snapshots.set(manifest, JSON.parse(JSON.stringify(manifest.entries)));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = {
|
|
81
|
+
MANIFEST_SCHEMA_VERSION,
|
|
82
|
+
manifestPath,
|
|
83
|
+
emptyManifest,
|
|
84
|
+
loadManifest,
|
|
85
|
+
getEntry,
|
|
86
|
+
relativePath,
|
|
87
|
+
absolutePath,
|
|
88
|
+
saveManifest,
|
|
89
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { loadProjectConfig } = require('../projectConfig');
|
|
5
|
+
const { DraftGoMcpClient } = require('../mcp/client');
|
|
6
|
+
const backendDefaults = require('./backend');
|
|
7
|
+
const { loadManifest, absolutePath } = require('./manifest');
|
|
8
|
+
const { hashFile } = require('./streams');
|
|
9
|
+
|
|
10
|
+
function versionValue(source) {
|
|
11
|
+
if (!source) return null;
|
|
12
|
+
if (source.base_version != null) return source.base_version;
|
|
13
|
+
if (source.base_revision != null) return source.base_revision;
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sameVersion(entry, remote) {
|
|
18
|
+
const local = versionValue(entry);
|
|
19
|
+
const current = versionValue(remote);
|
|
20
|
+
if (local == null || current == null) return true;
|
|
21
|
+
return String(local) === String(current);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function optionalHash(projectDir, relative) {
|
|
25
|
+
if (!relative) return null;
|
|
26
|
+
const file = absolutePath(projectDir, relative);
|
|
27
|
+
return fs.existsSync(file) ? (await hashFile(file)).hash : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function classify(entry, localHash, baseHash, remote) {
|
|
31
|
+
const manifestHash = entry.base_hash;
|
|
32
|
+
const remoteHash = remote && remote.content_hash || null;
|
|
33
|
+
const baseMatchesManifest = baseHash === manifestHash;
|
|
34
|
+
const localMatchesManifest = localHash === manifestHash;
|
|
35
|
+
const localMatchesRemote = Boolean(localHash && remoteHash && localHash === remoteHash);
|
|
36
|
+
const remoteMatchesManifest = Boolean(remoteHash && remoteHash === manifestHash);
|
|
37
|
+
const versionMatches = remote ? sameVersion(entry, remote) : null;
|
|
38
|
+
|
|
39
|
+
let state;
|
|
40
|
+
let recommendation = null;
|
|
41
|
+
if (!localHash) {
|
|
42
|
+
state = 'local_missing';
|
|
43
|
+
recommendation = `draftgo checkout ${entry.resource_type} ${entry.resource_id} --force`;
|
|
44
|
+
} else if (!baseHash) {
|
|
45
|
+
state = 'base_missing';
|
|
46
|
+
recommendation = `draftgo checkout ${entry.resource_type} ${entry.resource_id} --force`;
|
|
47
|
+
} else if (!remote) {
|
|
48
|
+
state = baseMatchesManifest
|
|
49
|
+
? (localMatchesManifest ? 'clean_local' : 'local_modified')
|
|
50
|
+
: 'local_metadata_corrupt';
|
|
51
|
+
} else if (localMatchesRemote && (!remoteMatchesManifest || !versionMatches || !baseMatchesManifest)) {
|
|
52
|
+
state = remoteMatchesManifest ? 'metadata_stale' : 'committed_unrecorded';
|
|
53
|
+
recommendation = `draftgo reconcile ${entry.resource_type} ${entry.resource_id}`;
|
|
54
|
+
} else if (!baseMatchesManifest) {
|
|
55
|
+
state = 'local_metadata_corrupt';
|
|
56
|
+
recommendation = `draftgo checkout ${entry.resource_type} ${entry.resource_id} --force`;
|
|
57
|
+
} else if (remoteMatchesManifest && versionMatches) {
|
|
58
|
+
state = localMatchesManifest ? 'clean' : 'local_modified';
|
|
59
|
+
} else if (localMatchesManifest) {
|
|
60
|
+
state = 'remote_changed';
|
|
61
|
+
recommendation = `draftgo checkout ${entry.resource_type} ${entry.resource_id}`;
|
|
62
|
+
} else {
|
|
63
|
+
state = 'diverged';
|
|
64
|
+
recommendation = `draftgo commit ${entry.resource_type} ${entry.resource_id}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
state,
|
|
69
|
+
local_hash: localHash,
|
|
70
|
+
base_file_hash: baseHash,
|
|
71
|
+
manifest_hash: manifestHash,
|
|
72
|
+
remote_hash: remoteHash,
|
|
73
|
+
manifest_version: versionValue(entry),
|
|
74
|
+
remote_version: versionValue(remote),
|
|
75
|
+
version_matches: versionMatches,
|
|
76
|
+
local_matches_remote: localMatchesRemote,
|
|
77
|
+
recommendation,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function inspectEntry(projectDir, entry, remote = null) {
|
|
82
|
+
const [localHash, baseHash] = await Promise.all([
|
|
83
|
+
optionalHash(projectDir, entry.local_path),
|
|
84
|
+
optionalHash(projectDir, entry.base_path),
|
|
85
|
+
]);
|
|
86
|
+
return { ...entry, ...classify(entry, localHash, baseHash, remote) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function openMetadataSession(config, options = {}) {
|
|
90
|
+
if (options.client && options.tools) return { client: options.client, tools: options.tools };
|
|
91
|
+
const client = options.client || new DraftGoMcpClient(config);
|
|
92
|
+
await client.initialize(options);
|
|
93
|
+
const tools = options.tools || await client.listAllTools(options);
|
|
94
|
+
return { client, tools };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function inspectRemoteCheckouts(projectDir, options = {}) {
|
|
98
|
+
const config = options.config || loadProjectConfig(projectDir);
|
|
99
|
+
const manifest = options.manifest || loadManifest(projectDir);
|
|
100
|
+
const backend = { ...backendDefaults, ...(options.backend || {}) };
|
|
101
|
+
const session = options.backend && typeof options.backend.resolveMetadata === 'function'
|
|
102
|
+
? { client: options.client || {}, tools: options.tools || [] }
|
|
103
|
+
: await openMetadataSession(config, options);
|
|
104
|
+
const entries = options.entries || Object.values(manifest.entries);
|
|
105
|
+
const { mapBounded } = require('../mcp/parallel');
|
|
106
|
+
return mapBounded(entries, async (entry) => {
|
|
107
|
+
const remote = await backend.resolveMetadata(config, entry.resource_type, entry.resource_id, {
|
|
108
|
+
...options,
|
|
109
|
+
...session,
|
|
110
|
+
projectDir,
|
|
111
|
+
clientInitialized: true,
|
|
112
|
+
});
|
|
113
|
+
return inspectEntry(projectDir, entry, remote);
|
|
114
|
+
}, options);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
versionValue,
|
|
119
|
+
sameVersion,
|
|
120
|
+
classify,
|
|
121
|
+
inspectEntry,
|
|
122
|
+
openMetadataSession,
|
|
123
|
+
inspectRemoteCheckouts,
|
|
124
|
+
};
|
|
@@ -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
|
+
};
|