draftgo-cli 4.0.24 → 4.0.26

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.
Files changed (88) hide show
  1. package/README.md +23 -37
  2. package/package.json +3 -5
  3. package/resources/skill/SKILL.md +9 -5
  4. package/resources/skill/manifest.json +2 -5
  5. package/resources/skill/references/ai.md +41 -0
  6. package/resources/skill/references/app-api.md +2 -50
  7. package/resources/skill/references/architecture.md +1 -1
  8. package/resources/skill/references/chat-sdk.md +29 -37
  9. package/resources/skill/references/checkout.md +4 -4
  10. package/resources/skill/references/data.md +0 -46
  11. package/resources/skill/references/delivery.md +3 -3
  12. package/resources/skill/references/diagnostics.md +10 -11
  13. package/resources/skill/references/frontend.md +23 -20
  14. package/resources/skill/references/mcp.md +4 -14
  15. package/resources/skill/references/methods.md +15 -68
  16. package/resources/skill/references/modules.md +23 -44
  17. package/resources/skill/references/runtime.md +3 -20
  18. package/resources/skill/story/SKILL.md +2 -2
  19. package/src/apiContractCache.js +14 -6
  20. package/src/cli.js +0 -7
  21. package/src/commandRegistry.js +0 -6
  22. package/src/commands/api.js +87 -17
  23. package/src/commands/apiKey.js +2 -6
  24. package/src/commands/autoPush.js +15 -51
  25. package/src/commands/capabilities.js +22 -15
  26. package/src/commands/check.js +19 -53
  27. package/src/commands/checkout.js +1 -4
  28. package/src/commands/clean.js +1 -1
  29. package/src/commands/commit.js +1 -4
  30. package/src/commands/components.js +12 -8
  31. package/src/commands/conflict.js +4 -6
  32. package/src/commands/conflicts.js +1 -2
  33. package/src/commands/connect.js +0 -8
  34. package/src/commands/delete.js +15 -11
  35. package/src/commands/deploy.js +64 -26
  36. package/src/commands/diff.js +1 -4
  37. package/src/commands/group.js +2 -3
  38. package/src/commands/help.js +22 -43
  39. package/src/commands/init.js +13 -6
  40. package/src/commands/local.js +4 -1
  41. package/src/commands/map.js +138 -23
  42. package/src/commands/reconcile.js +1 -15
  43. package/src/commands/role.js +1 -2
  44. package/src/commands/status.js +12 -40
  45. package/src/commands/update.js +18 -24
  46. package/src/commands/verify.js +8 -7
  47. package/src/commands/worklog.js +11 -5
  48. package/src/contractCompatibility.js +10 -2
  49. package/src/localRuntime/compose.js +41 -27
  50. package/src/localRuntime/detect.js +6 -6
  51. package/src/localRuntime/index.js +47 -47
  52. package/src/localRuntime/services.js +2 -39
  53. package/src/mcp/client.js +99 -134
  54. package/src/mcp/parallel.js +25 -2
  55. package/src/mcp/protocol.js +38 -9
  56. package/src/mcp/tools.js +10 -19
  57. package/src/projectConfig.js +1 -4
  58. package/src/{workspaceHealth.js → projectHealth.js} +5 -5
  59. package/src/projectMap.js +1 -1
  60. package/src/runtimeFiles.js +2 -1
  61. package/src/worklog.js +3 -2
  62. package/src/worktree/backend.js +127 -15
  63. package/src/worktree/index.js +64 -22
  64. package/src/worktree/locks.js +52 -0
  65. package/src/worktree/manifest.js +18 -4
  66. package/src/worktree/status.js +4 -2
  67. package/resources/custom-service-sdk/ai.go +0 -520
  68. package/resources/custom-service-sdk/ai_test.go +0 -156
  69. package/resources/custom-service-sdk/auth_test.go +0 -56
  70. package/resources/custom-service-sdk/billing.go +0 -596
  71. package/resources/custom-service-sdk/billing_test.go +0 -150
  72. package/resources/custom-service-sdk/go.mod +0 -3
  73. package/resources/custom-service-sdk/manifest.json +0 -77
  74. package/resources/custom-service-sdk/platform.go +0 -352
  75. package/resources/custom-service-sdk/platform_logger_test.go +0 -24
  76. package/resources/custom-service-sdk/registration_test.go +0 -39
  77. package/resources/custom-service-sdk/resources.go +0 -247
  78. package/resources/custom-service-sdk/resources_billing_test.go +0 -115
  79. package/resources/custom-service-sdk/resources_files_test.go +0 -57
  80. package/resources/custom-service-sdk/resources_scope_test.go +0 -92
  81. package/resources/custom-service-sdk/sdk.go +0 -209
  82. package/resources/skill/references/aihub.md +0 -116
  83. package/resources/skill/references/custom-services.md +0 -201
  84. package/src/commands/customService.js +0 -95
  85. package/src/commands/dataRange.js +0 -33
  86. package/src/commands/grant.js +0 -29
  87. package/src/commands/space.js +0 -41
  88. package/src/customServices.js +0 -484
@@ -1,29 +0,0 @@
1
- 'use strict';
2
-
3
- const log = require('../logger');
4
- const { callOperation } = require('./api');
5
-
6
- const OPERATIONS = Object.freeze({
7
- list: 'listAccessGrants',
8
- create: 'createAccessGrant',
9
- get: 'getAccessGrant',
10
- revoke: 'revokeAccessGrant',
11
- });
12
-
13
- function operationKey(value) {
14
- const action = String(value || 'list').trim().toLowerCase();
15
- return OPERATIONS[action] ? action : '';
16
- }
17
-
18
- async function grantCommand(projectDir, positional, flags = {}) {
19
- const action = operationKey(positional[0]);
20
- if (!action) {
21
- log.err('Usage: draftgo grant list|create|get|revoke [--input <json-file>]');
22
- return 1;
23
- }
24
- return callOperation(projectDir, OPERATIONS[action], flags);
25
- }
26
-
27
- module.exports = grantCommand;
28
- module.exports.OPERATIONS = OPERATIONS;
29
- module.exports.operationKey = operationKey;
@@ -1,41 +0,0 @@
1
- 'use strict';
2
-
3
- const log = require('../logger');
4
- const { callOperation } = require('./api');
5
-
6
- const OPERATIONS = Object.freeze({
7
- list: 'listSpaces',
8
- create: 'createSpace',
9
- get: 'getSpace',
10
- update: 'updateSpace',
11
- replace: 'replaceSpace',
12
- disable: 'deleteSpace',
13
- 'members.list': 'listWorkspaceMembers',
14
- 'members.add': 'addWorkspaceMember',
15
- 'members.get': 'getWorkspaceMember',
16
- 'members.update': 'updateWorkspaceMember',
17
- 'members.replace': 'replaceWorkspaceMember',
18
- 'members.remove': 'removeWorkspaceMember',
19
- });
20
-
21
- function operationKey(parts) {
22
- const values = parts.map((part) => String(part || '').trim().toLowerCase()).filter(Boolean);
23
- if (values.length === 1) return values[0] === 'delete' ? 'disable' : values[0];
24
- if (values.length === 2 && ['member', 'members'].includes(values[0])) {
25
- return `members.${values[1] === 'delete' ? 'remove' : values[1]}`;
26
- }
27
- return '';
28
- }
29
-
30
- async function spaceCommand(projectDir, positional, flags = {}) {
31
- const key = operationKey(positional);
32
- if (!OPERATIONS[key]) {
33
- log.err('Usage: draftgo space list|create|get|update|replace|disable | members list|add|get|update|replace|remove [--input <json-file>]');
34
- return 1;
35
- }
36
- return callOperation(projectDir, OPERATIONS[key], flags);
37
- }
38
-
39
- module.exports = spaceCommand;
40
- module.exports.OPERATIONS = OPERATIONS;
41
- module.exports.operationKey = operationKey;
@@ -1,484 +0,0 @@
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', 'service.json'];
12
- const MANAGED_MODULE_FILES = ['go.mod', 'go.sum', 'draftgo_local_main.go', '.draftgo-managed.json'];
13
- const MANAGED_SDK_DIRECTORY = '.draftgo-sdk';
14
- const SDK_BUNDLE_ROOT = path.resolve(__dirname, '..', 'resources', 'custom-service-sdk');
15
- const dependencyDirectivePattern = /^\s*\/\/\s*draftgo:require\s+([^\s@]+)@([^\s]+)\s*$/gm;
16
- function root(projectDir) { return path.join(projectDir, '.draftgo', 'worktree', 'custom-services'); }
17
- function baseRoot(projectDir) { return path.join(projectDir, '.draftgo', 'worktree', '.base', 'custom-services'); }
18
- function conflictRoot(projectDir, id) { return path.join(projectDir, '.draftgo', 'conflicts', 'custom-services', safe(id)); }
19
- function manifestPath(projectDir) { return path.join(root(projectDir), 'manifest.json'); }
20
- function safe(value) { return String(value).replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^\.+/, '').slice(0, 80) || 'service'; }
21
- function sha256(value) { return crypto.createHash('sha256').update(value).digest('hex'); }
22
- function relative(projectDir, file) { return path.relative(projectDir, file).replace(/\\/g, '/'); }
23
- function loadManifest(projectDir) {
24
- const file = manifestPath(projectDir);
25
- if (!fs.existsSync(file)) return { schema_version: 2, entries: {} };
26
- const value = JSON.parse(fs.readFileSync(file, 'utf8'));
27
- if (value && value.schema_version === 1) throw new WorktreeError('WORKTREE_SCHEMA_UNSUPPORTED', 'Custom-service worktree schema v1 is obsolete; create a new checkout with this CLI.');
28
- if (!value || value.schema_version !== 2 || !value.entries || Array.isArray(value.entries)) throw new WorktreeError('INVALID_WORKTREE_MANIFEST', 'Invalid custom-service manifest.');
29
- return value;
30
- }
31
- function atomicWrite(file, value) {
32
- fs.mkdirSync(path.dirname(file), { recursive: true });
33
- const temporary = `${file}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(8).toString('hex')}`;
34
- fs.writeFileSync(temporary, value); fs.renameSync(temporary, file);
35
- }
36
- function saveManifest(projectDir, value) { value.updated_at = new Date().toISOString(); atomicWrite(manifestPath(projectDir), `${JSON.stringify(value, null, 2)}\n`); }
37
- function entryKey(id) { return String(id); }
38
- function uniqueIDs(ids) { return [...new Set(ids.map(String).map((id) => id.trim()).filter(Boolean))]; }
39
- function serviceDir(projectDir, id, slug) { return path.join(root(projectDir), `${safe(slug || id)}-${safe(id)}`); }
40
- function serviceBaseDir(projectDir, id) { return path.join(baseRoot(projectDir), safe(id)); }
41
- function parseEnvelope(text, response) {
42
- let payload = {};
43
- try { payload = text ? JSON.parse(text) : {}; } catch { payload = { message: text }; }
44
- if (!response.ok || (payload.code && Number(payload.code) >= 400)) {
45
- const details = payload.error?.details || payload.details || payload;
46
- const error = new WorktreeError(payload.error?.code || payload.code || `HTTP_${response.status}`, payload.error?.message || payload.message || `DraftGo HTTP ${response.status}`, details);
47
- error.status = response.status; throw error;
48
- }
49
- return payload.data === undefined ? payload : payload.data;
50
- }
51
- async function jsonRequest(config, method, endpoint, body, options = {}) {
52
- const response = await (options.fetch || fetch)(`${config.server.replace(/\/+$/, '')}/api/${endpoint.replace(/^\/+/, '')}`, {
53
- method, headers: { Authorization: `Bearer ${config.token || config.sat}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
54
- body: body === undefined ? undefined : JSON.stringify(body), signal: options.signal,
55
- });
56
- return parseEnvelope(await response.text(), response);
57
- }
58
- async function metadata(config, id, options = {}) { return jsonRequest(config, 'GET', `content/custom-services/${encodeURIComponent(id)}/checkout`, undefined, options); }
59
- async function download(config, metadataValue, options = {}) {
60
- const url = new URL(metadataValue.download_url, `${config.server.replace(/\/+$/, '')}/`).toString();
61
- const response = await (options.fetch || fetch)(url, { headers: { Authorization: `Bearer ${config.token || config.sat}` }, signal: options.signal });
62
- if (!response.ok) parseEnvelope(await response.text(), response);
63
- const archive = Buffer.from(await response.arrayBuffer());
64
- if (sha256(archive) !== metadataValue.content_hash) throw new WorktreeError('HASH_MISMATCH', 'Downloaded custom-service archive hash mismatch.');
65
- return archive;
66
- }
67
- function archiveFiles(archive) {
68
- const zip = new AdmZip(archive); const result = {};
69
- for (const filename of FILES) {
70
- const entry = zip.getEntry(filename);
71
- if (!entry || entry.isDirectory) throw new WorktreeError('INVALID_SERVICE_ARCHIVE', `Custom-service archive is missing ${filename}.`);
72
- result[filename] = entry.getData();
73
- }
74
- const unexpected = zip.getEntries().filter((entry) => !entry.isDirectory && !FILES.includes(entry.entryName));
75
- if (unexpected.length) throw new WorktreeError('INVALID_SERVICE_ARCHIVE', `Unexpected archive entry ${unexpected[0].entryName}.`);
76
- JSON.parse(result['service.json'].toString('utf8'));
77
- return result;
78
- }
79
- function writeFiles(directory, files) { fs.mkdirSync(directory, { recursive: true }); for (const filename of FILES) atomicWrite(path.join(directory, filename), files[filename]); }
80
- function readFiles(directory) {
81
- const result = {};
82
- for (const filename of FILES) {
83
- const file = path.join(directory, filename);
84
- if (!fs.existsSync(file)) throw new WorktreeError('SERVICE_FILE_MISSING', `Custom-service worktree is missing ${filename}.`);
85
- result[filename] = fs.readFileSync(file);
86
- }
87
- try { JSON.parse(result['service.json'].toString('utf8')); } catch (error) { throw new WorktreeError('INVALID_SERVICE_METADATA', `service.json is invalid: ${error.message}`); }
88
- return result;
89
- }
90
- function createArchive(files) {
91
- const zip = new AdmZip();
92
- // Archive hashes are part of the checkout/commit concurrency contract. Keep
93
- // ZIP metadata deterministic so the same two user files always produce the
94
- // same content hash, independent of wall-clock time.
95
- for (const filename of FILES) {
96
- zip.addFile(filename, files[filename]);
97
- const entry = zip.getEntry(filename);
98
- if (entry?.header) entry.header.time = new Date(0);
99
- }
100
- return zip.toBuffer();
101
- }
102
- function fileHashes(files) { return Object.fromEntries(FILES.map((filename) => [filename, sha256(files[filename])])); }
103
- function checkoutHash(files) { return sha256(createArchive(files)); }
104
-
105
- function sdkFingerprint(files) {
106
- const hash = crypto.createHash('sha256');
107
- for (const filename of ['go.mod', 'go.sum']) { hash.update(filename); if (files[filename]) hash.update(files[filename]); }
108
- for (const filename of Object.keys(files).filter((item) => item.endsWith('.go')).sort()) { hash.update(filename); hash.update(files[filename]); }
109
- return hash.digest('hex');
110
- }
111
-
112
- function loadSDKBundle() {
113
- const manifestFile = path.join(SDK_BUNDLE_ROOT, 'manifest.json');
114
- if (!fs.existsSync(manifestFile)) throw new WorktreeError('LOCAL_SDK_MISSING', 'draftgo-cli is missing its managed custom-service SDK; reinstall or upgrade the CLI.');
115
- const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
116
- if (!manifest || manifest.schema_version !== 1 || !/^[a-f0-9]{64}$/.test(String(manifest.fingerprint || '')) || !Array.isArray(manifest.files)) {
117
- throw new WorktreeError('LOCAL_SDK_INVALID', 'draftgo-cli contains an invalid managed custom-service SDK manifest.');
118
- }
119
- const files = {};
120
- for (const item of manifest.files) {
121
- const filename = String(item?.path || '');
122
- if (!/^(?:(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+\.go|go\.mod|go\.sum)$/.test(filename) || filename.includes('..')) {
123
- throw new WorktreeError('LOCAL_SDK_INVALID', `Managed custom-service SDK contains an invalid path: ${filename || '<empty>'}.`);
124
- }
125
- const content = fs.readFileSync(path.join(SDK_BUNDLE_ROOT, filename));
126
- if (sha256(content) !== item.sha256) throw new WorktreeError('LOCAL_SDK_INVALID', `Managed custom-service SDK hash mismatch: ${filename}.`);
127
- files[filename] = content;
128
- }
129
- if (!files['go.mod'] || !Object.keys(files).some((filename) => filename.endsWith('.go'))) {
130
- throw new WorktreeError('LOCAL_SDK_INVALID', 'Managed custom-service SDK is incomplete.');
131
- }
132
- if (sdkFingerprint(files) !== manifest.fingerprint) throw new WorktreeError('LOCAL_SDK_INVALID', 'Managed custom-service SDK fingerprint mismatch.');
133
- return { manifest, files };
134
- }
135
-
136
- function dependencyRequirements(source) {
137
- const byModule = new Map();
138
- for (const match of String(source).matchAll(dependencyDirectivePattern)) {
139
- const moduleName = match[1].trim(); const version = match[2].trim();
140
- if (byModule.has(moduleName) && byModule.get(moduleName) !== version) {
141
- throw new WorktreeError('INVALID_SERVICE_DEPENDENCY', `Dependency ${moduleName} declares conflicting versions.`);
142
- }
143
- byModule.set(moduleName, version);
144
- }
145
- return [...byModule.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([module, version]) => ({ module, version }));
146
- }
147
-
148
- function sdkGoVersion(bundle) {
149
- const match = bundle.files['go.mod'].toString('utf8').match(/^go\s+([^\s]+)\s*$/m);
150
- if (!match) throw new WorktreeError('LOCAL_SDK_INVALID', 'Managed custom-service SDK go.mod has no Go version.');
151
- return match[1];
152
- }
153
-
154
- function managedModule(source, bundle) {
155
- const requirements = dependencyRequirements(source);
156
- const lines = [
157
- '// Code generated by draftgo-cli. DO NOT EDIT.',
158
- '// Local development only; DraftGo uploads only service.go and service.json.',
159
- 'module draftgo.local/customservice', '', `go ${sdkGoVersion(bundle)}`, '', 'require (', '\tdraftgo/sdk v0.0.0',
160
- ...requirements.map((item) => `\t${item.module} ${item.version}`),
161
- ')', '', `replace draftgo/sdk => ./${MANAGED_SDK_DIRECTORY}`, '',
162
- ];
163
- return { content: `${lines.join('\n')}`, requirements };
164
- }
165
-
166
- function syncManagedDevelopmentFiles(directory) {
167
- const bundle = loadSDKBundle();
168
- const source = fs.readFileSync(path.join(directory, 'service.go'), 'utf8');
169
- const moduleValue = managedModule(source, bundle); const moduleHash = sha256(moduleValue.content);
170
- const markerFile = path.join(directory, '.draftgo-managed.json');
171
- let prior = null;
172
- try { prior = JSON.parse(fs.readFileSync(markerFile, 'utf8')); } catch { /* generated state is disposable */ }
173
- const preserveGoSum = prior?.schema_version === 1 && prior.module_hash === moduleHash && fs.existsSync(path.join(directory, 'go.sum'));
174
-
175
- const sdkDirectory = path.join(directory, MANAGED_SDK_DIRECTORY);
176
- fs.rmSync(sdkDirectory, { recursive: true, force: true }); fs.mkdirSync(sdkDirectory, { recursive: true });
177
- for (const [filename, content] of Object.entries(bundle.files)) atomicWrite(path.join(sdkDirectory, filename), content);
178
- atomicWrite(path.join(directory, 'go.mod'), moduleValue.content);
179
- if (!preserveGoSum) atomicWrite(path.join(directory, 'go.sum'), '');
180
- atomicWrite(path.join(directory, 'draftgo_local_main.go'), `// Code generated by draftgo-cli. DO NOT EDIT.\n// Local compile entrypoint; DraftGo never uploads this file.\npackage main\n\nimport "draftgo/sdk"\n\nfunc main() {\n\tapp := sdk.NewApp()\n\tRegister(app)\n}\n`);
181
- atomicWrite(markerFile, `${JSON.stringify({
182
- schema_version: 1, owner: 'draftgo-cli', notice: 'Generated local-development files; do not edit or commit.',
183
- sdk_fingerprint: bundle.manifest.fingerprint, module_hash: moduleHash, requirements: moduleValue.requirements,
184
- }, null, 2)}\n`);
185
- return { sdk_fingerprint: bundle.manifest.fingerprint, module_hash: moduleHash, requirements: moduleValue.requirements };
186
- }
187
- function remoteFileHashes(remote) {
188
- if (remote.checkout_schema_version != null && Number(remote.checkout_schema_version) !== 2) throw new WorktreeError('WORKTREE_SCHEMA_UNSUPPORTED', 'DraftGo custom-service checkout schema is not supported; upgrade DraftGo and create a new checkout.');
189
- return { 'service.go': remote.source_hash, 'service.json': remote.metadata_hash };
190
- }
191
- function filesMatchRemote(files, remote) {
192
- const local = fileHashes(files); const expected = remoteFileHashes(remote);
193
- return FILES.every((filename) => expected[filename] && local[filename] === expected[filename]);
194
- }
195
- function canonicalFiles(files, remote) {
196
- if (!remote || typeof remote.service_metadata_json !== 'string') {
197
- throw new WorktreeError('INCOMPLETE_CHECKOUT_METADATA', 'DraftGo did not return canonical custom-service metadata.');
198
- }
199
- const canonical = { ...files, 'service.json': Buffer.from(remote.service_metadata_json, 'utf8') };
200
- JSON.parse(canonical['service.json'].toString('utf8'));
201
- const actual = fileHashes(canonical); const expected = remoteFileHashes(remote);
202
- for (const filename of FILES) {
203
- if (!/^[a-f0-9]{64}$/i.test(String(expected[filename] || '')) || actual[filename] !== expected[filename]) {
204
- throw new WorktreeError('HASH_MISMATCH', `Canonical custom-service ${filename} does not match checkout metadata.`, {
205
- filename, expected_hash: expected[filename] || null, actual_hash: actual[filename],
206
- });
207
- }
208
- }
209
- return canonical;
210
- }
211
- function applyRemoteEntry(entry, remote, files) {
212
- const canonical = canonicalFiles(files, remote);
213
- writeFiles(entry.local_dir, canonical); writeFiles(entry.base_dir, canonical);
214
- entry.title = remote.title; entry.slug = JSON.parse(canonical['service.json'].toString('utf8')).slug;
215
- entry.file_hashes = fileHashes(canonical); entry.base_hash = remote.content_hash;
216
- entry.base_revision = remote.base_revision; entry.base_etag = remote.etag;
217
- entry.checkout_source = remote.checkout_source; entry.updated_at = remote.updated_at;
218
- entry.updated_by = remote.updated_by; entry.committed_at = new Date().toISOString();
219
- return canonical;
220
- }
221
- function localState(entry) {
222
- const local = readFiles(entry.local_dir); const base = readFiles(entry.base_dir);
223
- const localHashes = fileHashes(local); const baseHashes = fileHashes(base);
224
- const baseValid = FILES.every((filename) => baseHashes[filename] === entry.file_hashes[filename]);
225
- return { local, base, local_hashes: localHashes, base_hashes: baseHashes, base_valid: baseValid,
226
- modified: FILES.some((filename) => localHashes[filename] !== baseHashes[filename]) };
227
- }
228
-
229
- async function checkout(projectDir, ids, options = {}) {
230
- const config = options.config || loadProjectConfig(projectDir); const manifest = loadManifest(projectDir);
231
- const targets = uniqueIDs(ids);
232
- const settled = await Promise.allSettled(targets.map(async (id) => {
233
- const remote = await metadata(config, id, options); const key = entryKey(id); const old = manifest.entries[key];
234
- if (old && fs.existsSync(old.local_dir) && !options.force && localState(old).modified) {
235
- throw new WorktreeError('LOCAL_CHANGES_PRESENT', `Refusing to replace locally modified custom service ${id}; commit it or use checkout --force.`);
236
- }
237
- const archive = await download(config, remote, options); const files = archiveFiles(archive);
238
- const service = JSON.parse(files['service.json'].toString('utf8')); const localDir = old?.local_dir || serviceDir(projectDir, id, service.slug); const baseDir = serviceBaseDir(projectDir, id);
239
- writeFiles(localDir, files); writeFiles(baseDir, files);
240
- syncManagedDevelopmentFiles(localDir);
241
- const entry = { server: config.server, resource_type: 'custom_services', resource_id: String(id), title: remote.title,
242
- slug: service.slug, local_dir: localDir, base_dir: baseDir, local_path: relative(projectDir, localDir), base_path: relative(projectDir, baseDir),
243
- content_type: remote.content_type, base_revision: remote.base_revision, base_etag: remote.etag,
244
- base_hash: remote.content_hash, file_hashes: fileHashes(files), checkout_source: remote.checkout_source,
245
- checked_out_at: new Date().toISOString(), updated_at: remote.updated_at, updated_by: remote.updated_by };
246
- return entry;
247
- }));
248
- const results = settled.filter((item) => item.status === 'fulfilled').map((item) => item.value);
249
- for (const entry of results) manifest.entries[entryKey(entry.resource_id)] = entry;
250
- if (results.length) saveManifest(projectDir, manifest);
251
- const failures = settled.map((item, index) => ({ item, id: targets[index] })).filter(({ item }) => item.status === 'rejected');
252
- if (failures.length) {
253
- const error = failures[0].item.reason;
254
- error.details = { ...(error.details || {}), batch: {
255
- completed: results.map((entry) => ({ resource_id: entry.resource_id, status: 'checked_out' })),
256
- failed: failures.map(({ item, id }) => ({ resource_id: id, status: 'failed', code: item.reason.code || 'CHECKOUT_FAILED', message: item.reason.message })),
257
- not_started: [],
258
- } };
259
- throw error;
260
- }
261
- return results;
262
- }
263
- function getEntry(projectDir, id) { return loadManifest(projectDir).entries[entryKey(id)] || null; }
264
- function diffFile(baseFile, localFile, filename) {
265
- if (fs.readFileSync(baseFile).equals(fs.readFileSync(localFile))) return '';
266
- const result = spawnSync('git', ['diff', '--no-index', '--', baseFile, localFile], { encoding: 'utf8', windowsHide: true });
267
- if (result.error || ![0, 1].includes(result.status)) return `${filename}: changed\n`;
268
- return String(result.stdout || '').replaceAll(baseFile, `a/${filename}`).replaceAll(localFile, `b/${filename}`);
269
- }
270
- function diff(projectDir, id) {
271
- const entry = getEntry(projectDir, id); if (!entry) throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `Custom service ${id} is not checked out.`);
272
- const state = localState(entry);
273
- const files = FILES.map((filename) => {
274
- const output = diffFile(path.join(entry.base_dir, filename), path.join(entry.local_dir, filename), filename);
275
- return { filename, changed: state.local_hashes[filename] !== state.base_hashes[filename], output };
276
- });
277
- const result = { changed: files.some((file) => file.changed), entry,
278
- files: files.map(({ filename, changed }) => ({ filename, changed })), output: files.map((file) => file.output).filter(Boolean).join('\n') };
279
- Object.defineProperty(result, 'file_outputs', { value: files, enumerable: false });
280
- return result;
281
- }
282
- async function writeConflict(projectDir, entry, remote, archive) {
283
- 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');
284
- writeFiles(baseDir, readFiles(entry.base_dir)); writeFiles(localDir, readFiles(entry.local_dir)); writeFiles(remoteDir, archiveFiles(archive));
285
- const record = { schema_version: 2, status: 'unresolved', code: 'RESOURCE_VERSION_CONFLICT', resource_type: 'custom_services', resource_id: entry.resource_id,
286
- base_path: relative(projectDir, baseDir), local_path: relative(projectDir, localDir), remote_path: relative(projectDir, remoteDir), worktree_local_path: entry.local_path,
287
- expected_revision: entry.base_revision, actual_revision: remote.base_revision, expected_hash: entry.base_hash, actual_hash: remote.content_hash,
288
- actual_etag: remote.etag, actual_updated_at: remote.updated_at, created_at: new Date().toISOString() };
289
- atomicWrite(path.join(directory, 'conflict.json'), `${JSON.stringify(record, null, 2)}\n`); return record;
290
- }
291
- function serviceConflicts(projectDir, options = {}) {
292
- const directory = path.join(projectDir, '.draftgo', 'conflicts', 'custom-services'); if (!fs.existsSync(directory)) return [];
293
- return fs.readdirSync(directory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).flatMap((entry) => {
294
- const file = path.join(directory, entry.name, 'conflict.json'); if (!fs.existsSync(file)) return [];
295
- const record = JSON.parse(fs.readFileSync(file, 'utf8')); return options.all || record.status === 'unresolved' ? [{ ...record, manifest_path: relative(projectDir, file) }] : [];
296
- });
297
- }
298
- function showConflict(projectDir, id) {
299
- const record = serviceConflicts(projectDir, { all: true }).find((entry) => String(entry.resource_id) === String(id));
300
- if (!record) throw new WorktreeError('CONFLICT_NOT_FOUND', `No custom-service conflict found for ${id}.`); return record;
301
- }
302
- function resolveConflict(projectDir, id) {
303
- const record = showConflict(projectDir, id); if (record.status !== 'unresolved') throw new WorktreeError('CONFLICT_ALREADY_RESOLVED', `Custom-service conflict ${id} is already resolved.`);
304
- 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.`);
305
- const remoteDir = path.resolve(projectDir, record.remote_path); const remoteFiles = readFiles(remoteDir); writeFiles(entry.base_dir, remoteFiles);
306
- 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();
307
- manifest.entries[entryKey(id)] = entry; saveManifest(projectDir, manifest);
308
- const resolved = { ...record, status: 'resolved', resolved_at: new Date().toISOString() }; delete resolved.manifest_path;
309
- atomicWrite(path.resolve(projectDir, record.manifest_path), `${JSON.stringify(resolved, null, 2)}\n`); return { ...resolved, manifest_path: record.manifest_path };
310
- }
311
- async function commit(projectDir, ids, options = {}) {
312
- const config = options.config || loadProjectConfig(projectDir); const manifest = loadManifest(projectDir);
313
- const targets = uniqueIDs(ids);
314
- const preflight = await Promise.allSettled(targets.map(async (id) => {
315
- const entry = manifest.entries[entryKey(id)]; if (!entry) throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `Custom service ${id} is not checked out.`);
316
- if (entry.server !== config.server) throw new WorktreeError('CHECKOUT_SERVER_MISMATCH', 'Checkout belongs to a different DraftGo server.');
317
- syncManagedDevelopmentFiles(entry.local_dir);
318
- const state = localState(entry); if (!state.base_valid) throw new WorktreeError('BASE_HASH_MISMATCH', `Custom service ${id} base does not match its manifest.`);
319
- const createDraft = Boolean(options.ensureDraft) && entry.checkout_source === 'live';
320
- if (!state.modified && !createDraft) return { id, entry, state, unchanged: true };
321
- const current = await metadata(config, id, options);
322
- if (current.content_hash !== entry.base_hash || String(current.base_revision) !== String(entry.base_revision)) {
323
- const remoteArchive = await download(config, current, options); const conflict = await writeConflict(projectDir, entry, current, remoteArchive);
324
- throw new WorktreeError('RESOURCE_VERSION_CONFLICT', `Custom service ${id} changed remotely; conflict materials were preserved.`, conflict);
325
- }
326
- return { id, entry, state, current, archive: createArchive(state.local) };
327
- }));
328
- const preflightFailures = preflight.map((item, index) => ({ item, id: targets[index] })).filter(({ item }) => item.status === 'rejected');
329
- if (preflightFailures.length) {
330
- const error = preflightFailures[0].item.reason;
331
- error.details = { ...(error.details || {}), batch: {
332
- completed: [],
333
- failed: preflightFailures.map(({ item, id }) => ({ resource_id: id, status: 'failed', phase: 'preflight', code: item.reason.code || 'PREFLIGHT_FAILED', message: item.reason.message })),
334
- not_started: preflight.filter((item) => item.status === 'fulfilled').map((item) => ({ resource_id: item.value.id, status: 'not_started', phase: 'preflight' })),
335
- } };
336
- throw error;
337
- }
338
- const prepared = preflight.map((item) => item.value);
339
- const uploaded = await Promise.allSettled(prepared.map(async (item) => {
340
- const { id, entry, current, archive, state } = item;
341
- if (item.unchanged) return { resource_type: 'custom_services', resource_id: String(id), status: 'unchanged' };
342
- const url = new URL(current.commit_url, `${config.server.replace(/\/+$/, '')}/`).toString();
343
- const response = await (options.fetch || fetch)(url, { method: 'PUT', headers: { Authorization: `Bearer ${config.token || config.sat}`, 'Content-Type': current.content_type,
344
- 'Content-Length': String(archive.length), 'X-Content-SHA256': sha256(archive), 'If-Match': entry.base_etag }, body: archive, signal: options.signal });
345
- let committed;
346
- try { committed = parseEnvelope(await response.text(), response); } catch (error) {
347
- 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); }
348
- throw error;
349
- }
350
- applyRemoteEntry(entry, committed, state.local); syncManagedDevelopmentFiles(entry.local_dir); manifest.entries[entryKey(id)] = entry;
351
- return { resource_type: 'custom_services', resource_id: String(id), status: 'committed', revision: committed.base_revision, hash: committed.content_hash };
352
- }));
353
- const results = uploaded.filter((item) => item.status === 'fulfilled').map((item) => item.value);
354
- if (results.some((item) => item.status === 'committed')) saveManifest(projectDir, manifest);
355
- const uploadFailures = uploaded.map((item, index) => ({ item, id: prepared[index].id })).filter(({ item }) => item.status === 'rejected');
356
- if (uploadFailures.length) {
357
- const error = uploadFailures[0].item.reason;
358
- error.details = { ...(error.details || {}), batch: {
359
- completed: results,
360
- failed: uploadFailures.map(({ item, id }) => ({ resource_id: id, status: 'failed', phase: 'upload', code: item.reason.code || 'COMMIT_FAILED', message: item.reason.message, remote_change_possible: true })),
361
- not_started: [],
362
- } };
363
- throw error;
364
- }
365
- return results;
366
- }
367
- async function inspectRemote(projectDir, options = {}) {
368
- const config = options.config || loadProjectConfig(projectDir);
369
- const requested = options.ids ? new Set(options.ids.map(String)) : null;
370
- const entries = Object.values(loadManifest(projectDir).entries)
371
- .filter((entry) => !requested || requested.has(String(entry.resource_id)));
372
- return Promise.all(entries.map(async (entry) => {
373
- 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);
374
- const localArchiveHash = checkoutHash(state.local); let status;
375
- if (remoteMatches) status = state.modified ? 'local_modified' : 'clean';
376
- else if (!state.modified) status = 'remote_changed';
377
- else if (filesMatchRemote(state.local, remote)) status = 'committed_unrecorded';
378
- else status = 'diverged';
379
- return { ...entry, state: status, remote_hash: remote.content_hash, remote_revision: remote.base_revision, remote_etag: remote.etag, local_hash: localArchiveHash };
380
- }));
381
- }
382
- function currentServiceMetadata(entry) { return JSON.parse(readFiles(entry.local_dir)['service.json'].toString('utf8')); }
383
- function validationCurrent(entry) {
384
- const value = currentServiceMetadata(entry);
385
- return value.validation_status === 'passed' && String(value.validated_revision) === String(entry.base_revision)
386
- && /^[a-f0-9]{64}$/i.test(String(value.validated_hash || ''));
387
- }
388
- function syncCheckoutEntry(manifest, id, remote) {
389
- const entry = manifest.entries[entryKey(id)];
390
- if (!entry) throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `Custom service ${id} is not checked out.`);
391
- const state = localState(entry);
392
- if (state.modified) throw new WorktreeError('LOCAL_CHANGES_PRESENT', `Custom service ${id} changed locally during its remote lifecycle.`);
393
- applyRemoteEntry(entry, remote, state.local); syncManagedDevelopmentFiles(entry.local_dir); manifest.entries[entryKey(id)] = entry;
394
- return entry;
395
- }
396
- async function validateCommitted(projectDir, id, config, manifest, options = {}) {
397
- const entry = manifest.entries[entryKey(id)];
398
- if (!entry) throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `Custom service ${id} is not checked out.`);
399
- if (validationCurrent(entry)) return { validation_status: 'passed', revision: entry.base_revision, checkout: null, cached: true };
400
- const result = await jsonRequest(config, 'POST', `scripts/${encodeURIComponent(id)}/draft/validate`, { revision: entry.base_revision }, options);
401
- if (!result.checkout) throw new WorktreeError('INCOMPLETE_CHECKOUT_METADATA', 'DraftGo validation response did not include checkout metadata.');
402
- syncCheckoutEntry(manifest, id, result.checkout);
403
- return result;
404
- }
405
- function validationStale(error) {
406
- return Boolean(error) && error.status === 409 && error.code === 'VALIDATION_FAILED'
407
- && error.details && error.details.reason === 'validation_stale';
408
- }
409
- async function validate(projectDir, id, options = {}) {
410
- const config = options.config || loadProjectConfig(projectDir); await commit(projectDir, [id], { ...options, config, ensureDraft: true });
411
- const manifest = loadManifest(projectDir); const result = await validateCommitted(projectDir, id, config, manifest, options);
412
- if (!result.cached) saveManifest(projectDir, manifest);
413
- return result;
414
- }
415
- function parseHandler(handler) {
416
- const value = String(handler || '').trim(); if (!value) return undefined;
417
- const parts = value.split(':');
418
- if (parts[0] === 'route' && parts.length >= 3) return { kind: 'route', method: parts[1], path: parts.slice(2).join(':') };
419
- if (parts[0] === 'event') return { kind: 'event', event: parts.slice(1).join(':') };
420
- if (parts[0] === 'scheduled') return { kind: 'scheduled', name: parts.slice(1).join(':') };
421
- return { kind: 'manual', name: value };
422
- }
423
- async function test(projectDir, id, input = {}, options = {}) {
424
- const config = options.config || loadProjectConfig(projectDir);
425
- const source = String(options.source || 'draft').toLowerCase();
426
- if (!['auto', 'draft', 'published'].includes(source)) throw new Error('source must be auto, draft, or published.');
427
- let manifest;
428
- let entry;
429
- let revision;
430
- if (source === 'draft') {
431
- await commit(projectDir, [id], { ...options, config, ensureDraft: true });
432
- manifest = loadManifest(projectDir);
433
- const validation = await validateCommitted(projectDir, id, config, manifest, options);
434
- entry = manifest.entries[entryKey(id)];
435
- revision = entry.base_revision;
436
- if (!validation.cached) saveManifest(projectDir, manifest);
437
- } else {
438
- manifest = loadManifest(projectDir);
439
- entry = manifest.entries[entryKey(id)];
440
- }
441
- const result = await jsonRequest(config, 'POST', `scripts/${encodeURIComponent(id)}/trial`, {
442
- source,
443
- input, selector: parseHandler(options.handler), headers: options.headers, user: options.user,
444
- test_write: Boolean(options.testWrite), side_effect_policy: options.sideEffectPolicy || 'deny', revision,
445
- }, options);
446
- if (result.checkout && entry) { syncCheckoutEntry(manifest, id, result.checkout); saveManifest(projectDir, manifest); }
447
- return result;
448
- }
449
- async function publish(projectDir, ids, options = {}) {
450
- const config = options.config || loadProjectConfig(projectDir); const targets = uniqueIDs(ids);
451
- await commit(projectDir, targets, { ...options, config, ensureDraft: true });
452
- const manifest = loadManifest(projectDir);
453
- const validations = await Promise.all(targets.map((id) => validateCommitted(projectDir, id, config, manifest, options)));
454
- if (validations.some((result) => !result.cached)) saveManifest(projectDir, manifest);
455
- const published = await Promise.allSettled(targets.map(async (id) => {
456
- let entry = manifest.entries[entryKey(id)];
457
- const requestPublish = () => jsonRequest(config, 'POST', `scripts/${encodeURIComponent(id)}/draft/publish`, { revision: entry.base_revision }, options);
458
- let result;
459
- try {
460
- result = await requestPublish();
461
- } catch (error) {
462
- if (!validationStale(error)) throw error;
463
- const validation = await jsonRequest(config, 'POST', `scripts/${encodeURIComponent(id)}/draft/validate`, { revision: entry.base_revision }, options);
464
- if (!validation.checkout) throw new WorktreeError('INCOMPLETE_CHECKOUT_METADATA', 'DraftGo validation response did not include checkout metadata.');
465
- entry = syncCheckoutEntry(manifest, id, validation.checkout);
466
- result = await requestPublish();
467
- }
468
- if (!result.checkout) throw new WorktreeError('INCOMPLETE_CHECKOUT_METADATA', 'DraftGo publish response did not include checkout metadata.');
469
- syncCheckoutEntry(manifest, id, result.checkout); return result;
470
- }));
471
- const results = published.filter((item) => item.status === 'fulfilled').map((item) => item.value);
472
- if (results.length) saveManifest(projectDir, manifest);
473
- const failures = published.map((item, index) => ({ item, id: targets[index] })).filter(({ item }) => item.status === 'rejected');
474
- if (failures.length) {
475
- const error = failures[0].item.reason;
476
- error.details = { ...(error.details || {}), batch: {
477
- completed: results, failed: failures.map(({ item, id }) => ({ resource_id: id, status: 'failed', code: item.reason.code || 'PUBLISH_FAILED', message: item.reason.message })), not_started: [],
478
- } };
479
- throw error;
480
- }
481
- return results;
482
- }
483
-
484
- module.exports = { FILES, MANAGED_MODULE_FILES, MANAGED_SDK_DIRECTORY, checkout, commit, diff, validate, test, publish, inspectRemote, loadManifest, getEntry, metadata, archiveFiles, createArchive, serviceConflicts, showConflict, resolveConflict, parseHandler, validationStale, dependencyRequirements, managedModule, syncManagedDevelopmentFiles, loadSDKBundle, sdkFingerprint };