draftgo-cli 3.0.55 → 4.0.1

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 (50) hide show
  1. package/README.md +112 -316
  2. package/package.json +5 -5
  3. package/resources/skill/SKILL.md +25 -24
  4. package/resources/skill/init/SKILL.md +5 -10
  5. package/resources/skill/manifest.json +2 -2
  6. package/resources/skill/references/aihub.md +10 -5
  7. package/resources/skill/references/chat-sdk.md +10 -0
  8. package/resources/skill/references/checkout.md +4 -4
  9. package/resources/skill/references/custom-services.md +65 -226
  10. package/resources/skill/references/data.md +3 -2
  11. package/resources/skill/references/frontend.md +96 -490
  12. package/resources/skill/references/mcp.md +39 -103
  13. package/resources/skill/references/runtime.md +3 -2
  14. package/resources/skill/story/SKILL.md +1 -2
  15. package/src/apiContractCache.js +112 -0
  16. package/src/cli.js +1 -21
  17. package/src/commandRegistry.js +6 -11
  18. package/src/commands/api.js +28 -8
  19. package/src/commands/check.js +1 -10
  20. package/src/commands/customService.js +2 -4
  21. package/src/commands/delete.js +23 -46
  22. package/src/commands/deploy.js +1 -1
  23. package/src/commands/help.js +16 -31
  24. package/src/commands/init.js +4 -10
  25. package/src/commands/listTargets.js +1 -1
  26. package/src/commands/local.js +2 -6
  27. package/src/commands/map.js +0 -11
  28. package/src/commands/status.js +1 -1
  29. package/src/commands/uninstall.js +3 -3
  30. package/src/commands/update.js +1 -1
  31. package/src/commands/verify.js +43 -21
  32. package/src/commands/{verifyUi.js → visualVerify.js} +28 -116
  33. package/src/commands/worklog.js +86 -0
  34. package/src/customServices.js +150 -33
  35. package/src/{localdev → localRuntime}/detect.js +1 -1
  36. package/src/{localdev → localRuntime}/mysqlClient.js +1 -1
  37. package/src/{localdev → localRuntime}/services.js +1 -1
  38. package/src/projectConfig.js +2 -0
  39. package/src/{installers/index.js → targets.js} +3 -5
  40. package/src/worklog.js +274 -0
  41. package/src/workspaceHealth.js +1 -1
  42. package/src/worktree/index.js +81 -51
  43. package/src/changelog.js +0 -276
  44. package/src/commands/changelog.js +0 -24
  45. package/src/commands/localDev.js +0 -9
  46. package/src/commands/sync.js +0 -46
  47. package/src/commands/task.js +0 -408
  48. package/src/commands/verifyUiCompat.js +0 -16
  49. /package/src/{localdev → localRuntime}/compose.js +0 -0
  50. /package/src/{localdev → localRuntime}/index.js +0 -0
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const log = require('../logger');
4
+ const {
5
+ appendItem,
6
+ mutateWorklog,
7
+ normalizeDate,
8
+ readWorklog,
9
+ renderWorklog,
10
+ resolveReference,
11
+ updateItem,
12
+ worklogPath,
13
+ } = require('../worklog');
14
+
15
+ function printUsage() {
16
+ log.err('Usage: draftgo work start|add|start-item|complete|show|list <value>');
17
+ }
18
+
19
+ function output(flags, value) {
20
+ if (flags.output === 'json') console.log(JSON.stringify(value, null, 2));
21
+ }
22
+
23
+ function latestReference(blocks) {
24
+ const block = blocks[blocks.length - 1];
25
+ return `${block.date}#${block.entries[block.entries.length - 1].number}`;
26
+ }
27
+
28
+ function resultFor(blocks, reference) {
29
+ const resolved = resolveReference(blocks, reference);
30
+ return { date: resolved.block.date, ...resolved.entry };
31
+ }
32
+
33
+ function add(projectDir, positional, flags, status) {
34
+ const title = positional.join(' ').trim();
35
+ if (!title) { printUsage(); return 1; }
36
+ const result = mutateWorklog(projectDir, (blocks) => {
37
+ const date = normalizeDate(flags.date);
38
+ const next = appendItem(blocks, title, status, flags.note ? [String(flags.note).trim()] : [], date);
39
+ return { blocks: next, reference: latestReference(next) };
40
+ });
41
+ output(flags, { path: result.path, item: resultFor(result.blocks, result.reference) });
42
+ if (flags.output !== 'json') log.ok(`Worklog item added: ${title}`);
43
+ return 0;
44
+ }
45
+
46
+ function change(projectDir, positional, flags, status) {
47
+ const reference = positional[0];
48
+ if (!reference) { printUsage(); return 1; }
49
+ const result = mutateWorklog(projectDir, (blocks) => {
50
+ const note = flags.note == null ? '' : String(flags.note).trim();
51
+ const next = updateItem(blocks, reference, status, note);
52
+ return { blocks: next, reference };
53
+ });
54
+ output(flags, { path: result.path, item: resultFor(result.blocks, result.reference) });
55
+ if (flags.output !== 'json') log.ok(`Worklog item ${result.reference} marked ${status}.`);
56
+ return 0;
57
+ }
58
+
59
+ function show(projectDir, flags) {
60
+ const blocks = readWorklog(projectDir);
61
+ output(flags, { path: worklogPath(projectDir), blocks });
62
+ if (flags.output !== 'json') console.log(renderWorklog(blocks) || 'Worklog is empty.');
63
+ return 0;
64
+ }
65
+
66
+ function list(projectDir, flags) {
67
+ const blocks = readWorklog(projectDir);
68
+ const items = blocks.flatMap((block) => block.entries.map((entry) => ({ date: block.date, ...entry })));
69
+ output(flags, { path: worklogPath(projectDir), items });
70
+ if (flags.output !== 'json') items.forEach((item) => log.info(`${item.date}#${item.number} [${item.status}] ${item.title}`));
71
+ return 0;
72
+ }
73
+
74
+ async function worklog(projectDir, positional = [], flags = {}) {
75
+ const operation = positional[0] || 'show';
76
+ if (operation === 'start') return add(projectDir, positional.slice(1), flags, 'active');
77
+ if (operation === 'add') return add(projectDir, positional.slice(1), flags, 'pending');
78
+ if (operation === 'start-item' || operation === 'start_item') return change(projectDir, positional.slice(1), flags, 'active');
79
+ if (operation === 'complete' || operation === 'done') return change(projectDir, positional.slice(1), flags, 'completed');
80
+ if (operation === 'show') return show(projectDir, flags);
81
+ if (operation === 'list') return list(projectDir, flags);
82
+ throw new Error(`Unknown worklog operation ${operation}.`);
83
+ }
84
+
85
+ worklog.printUsage = printUsage;
86
+ module.exports = worklog;
@@ -25,11 +25,12 @@ function loadManifest(projectDir) {
25
25
  }
26
26
  function atomicWrite(file, value) {
27
27
  fs.mkdirSync(path.dirname(file), { recursive: true });
28
- const temporary = `${file}.tmp-${process.pid}-${Date.now()}`;
28
+ const temporary = `${file}.tmp-${process.pid}-${Date.now()}-${crypto.randomBytes(8).toString('hex')}`;
29
29
  fs.writeFileSync(temporary, value); fs.renameSync(temporary, file);
30
30
  }
31
31
  function saveManifest(projectDir, value) { value.updated_at = new Date().toISOString(); atomicWrite(manifestPath(projectDir), `${JSON.stringify(value, null, 2)}\n`); }
32
32
  function entryKey(id) { return String(id); }
33
+ function uniqueIDs(ids) { return [...new Set(ids.map(String).map((id) => id.trim()).filter(Boolean))]; }
33
34
  function serviceDir(projectDir, id, slug) { return path.join(root(projectDir), `${safe(slug || id)}-${safe(id)}`); }
34
35
  function serviceBaseDir(projectDir, id) { return path.join(baseRoot(projectDir), safe(id)); }
35
36
  function parseEnvelope(text, response) {
@@ -95,6 +96,32 @@ function filesMatchRemote(files, remote) {
95
96
  const local = fileHashes(files); const expected = remoteFileHashes(remote);
96
97
  return FILES.every((filename) => expected[filename] && local[filename] === expected[filename]);
97
98
  }
99
+ function canonicalFiles(files, remote) {
100
+ if (!remote || typeof remote.service_metadata_json !== 'string') {
101
+ throw new WorktreeError('INCOMPLETE_CHECKOUT_METADATA', 'DraftGo did not return canonical custom-service metadata.');
102
+ }
103
+ const canonical = { ...files, 'service.json': Buffer.from(remote.service_metadata_json, 'utf8') };
104
+ JSON.parse(canonical['service.json'].toString('utf8'));
105
+ const actual = fileHashes(canonical); const expected = remoteFileHashes(remote);
106
+ for (const filename of FILES) {
107
+ if (!/^[a-f0-9]{64}$/i.test(String(expected[filename] || '')) || actual[filename] !== expected[filename]) {
108
+ throw new WorktreeError('HASH_MISMATCH', `Canonical custom-service ${filename} does not match checkout metadata.`, {
109
+ filename, expected_hash: expected[filename] || null, actual_hash: actual[filename],
110
+ });
111
+ }
112
+ }
113
+ return canonical;
114
+ }
115
+ function applyRemoteEntry(entry, remote, files) {
116
+ const canonical = canonicalFiles(files, remote);
117
+ writeFiles(entry.local_dir, canonical); writeFiles(entry.base_dir, canonical);
118
+ entry.title = remote.title; entry.slug = JSON.parse(canonical['service.json'].toString('utf8')).slug;
119
+ entry.file_hashes = fileHashes(canonical); entry.base_hash = remote.content_hash;
120
+ entry.base_revision = remote.base_revision; entry.base_etag = remote.etag;
121
+ entry.checkout_source = remote.checkout_source; entry.updated_at = remote.updated_at;
122
+ entry.updated_by = remote.updated_by; entry.committed_at = new Date().toISOString();
123
+ return canonical;
124
+ }
98
125
  function localState(entry) {
99
126
  const local = readFiles(entry.local_dir); const base = readFiles(entry.base_dir);
100
127
  const localHashes = fileHashes(local); const baseHashes = fileHashes(base);
@@ -103,8 +130,9 @@ function localState(entry) {
103
130
  modified: FILES.some((filename) => localHashes[filename] !== baseHashes[filename]) };
104
131
  }
105
132
  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) {
133
+ const config = options.config || loadProjectConfig(projectDir); const manifest = loadManifest(projectDir);
134
+ const targets = uniqueIDs(ids);
135
+ const settled = await Promise.allSettled(targets.map(async (id) => {
108
136
  const remote = await metadata(config, id, options); const key = entryKey(id); const old = manifest.entries[key];
109
137
  if (old && fs.existsSync(old.local_dir) && !options.force && localState(old).modified) {
110
138
  throw new WorktreeError('LOCAL_CHANGES_PRESENT', `Refusing to replace locally modified custom service ${id}; commit it or use checkout --force.`);
@@ -117,7 +145,20 @@ async function checkout(projectDir, ids, options = {}) {
117
145
  content_type: remote.content_type, base_revision: remote.base_revision, base_etag: remote.etag,
118
146
  base_hash: remote.content_hash, file_hashes: fileHashes(files), checkout_source: remote.checkout_source,
119
147
  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);
148
+ return entry;
149
+ }));
150
+ const results = settled.filter((item) => item.status === 'fulfilled').map((item) => item.value);
151
+ for (const entry of results) manifest.entries[entryKey(entry.resource_id)] = entry;
152
+ if (results.length) saveManifest(projectDir, manifest);
153
+ const failures = settled.map((item, index) => ({ item, id: targets[index] })).filter(({ item }) => item.status === 'rejected');
154
+ if (failures.length) {
155
+ const error = failures[0].item.reason;
156
+ error.details = { ...(error.details || {}), batch: {
157
+ completed: results.map((entry) => ({ resource_id: entry.resource_id, status: 'checked_out' })),
158
+ failed: failures.map(({ item, id }) => ({ resource_id: id, status: 'failed', code: item.reason.code || 'CHECKOUT_FAILED', message: item.reason.message })),
159
+ not_started: [],
160
+ } };
161
+ throw error;
121
162
  }
122
163
  return results;
123
164
  }
@@ -163,23 +204,34 @@ function resolveConflict(projectDir, id) {
163
204
  atomicWrite(path.resolve(projectDir, record.manifest_path), `${JSON.stringify(resolved, null, 2)}\n`); return { ...resolved, manifest_path: record.manifest_path };
164
205
  }
165
206
  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) {
207
+ const config = options.config || loadProjectConfig(projectDir); const manifest = loadManifest(projectDir);
208
+ const targets = uniqueIDs(ids);
209
+ const preflight = await Promise.allSettled(targets.map(async (id) => {
169
210
  const entry = manifest.entries[entryKey(id)]; if (!entry) throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `Custom service ${id} is not checked out.`);
170
211
  if (entry.server !== config.server) throw new WorktreeError('CHECKOUT_SERVER_MISMATCH', 'Checkout belongs to a different DraftGo server.');
171
212
  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; }
213
+ if (!state.modified) return { id, entry, state, unchanged: true };
173
214
  const current = await metadata(config, id, options);
174
215
  if (current.content_hash !== entry.base_hash || String(current.base_revision) !== String(entry.base_revision)) {
175
216
  const remoteArchive = await download(config, current, options); const conflict = await writeConflict(projectDir, entry, current, remoteArchive);
176
217
  throw new WorktreeError('RESOURCE_VERSION_CONFLICT', `Custom service ${id} changed remotely; conflict materials were preserved.`, conflict);
177
218
  }
178
- prepared.push({ id, entry, state, current, archive: createArchive(state.local) });
219
+ return { id, entry, state, current, archive: createArchive(state.local) };
220
+ }));
221
+ const preflightFailures = preflight.map((item, index) => ({ item, id: targets[index] })).filter(({ item }) => item.status === 'rejected');
222
+ if (preflightFailures.length) {
223
+ const error = preflightFailures[0].item.reason;
224
+ error.details = { ...(error.details || {}), batch: {
225
+ completed: [],
226
+ failed: preflightFailures.map(({ item, id }) => ({ resource_id: id, status: 'failed', phase: 'preflight', code: item.reason.code || 'PREFLIGHT_FAILED', message: item.reason.message })),
227
+ not_started: preflight.filter((item) => item.status === 'fulfilled').map((item) => ({ resource_id: item.value.id, status: 'not_started', phase: 'preflight' })),
228
+ } };
229
+ throw error;
179
230
  }
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; }
231
+ const prepared = preflight.map((item) => item.value);
232
+ const uploaded = await Promise.allSettled(prepared.map(async (item) => {
233
+ const { id, entry, current, archive, state } = item;
234
+ if (item.unchanged) return { resource_type: 'custom_services', resource_id: String(id), status: 'unchanged' };
183
235
  const url = new URL(current.commit_url, `${config.server.replace(/\/+$/, '')}/`).toString();
184
236
  const response = await (options.fetch || fetch)(url, { method: 'PUT', headers: { Authorization: `Bearer ${config.token || config.sat}`, 'Content-Type': current.content_type,
185
237
  'Content-Length': String(archive.length), 'X-Content-SHA256': sha256(archive), 'If-Match': entry.base_etag }, body: archive, signal: options.signal });
@@ -188,12 +240,20 @@ async function commit(projectDir, ids, options = {}) {
188
240
  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
241
  throw error;
190
242
  }
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 });
243
+ applyRemoteEntry(entry, committed, state.local); manifest.entries[entryKey(id)] = entry;
244
+ return { resource_type: 'custom_services', resource_id: String(id), status: 'committed', revision: committed.base_revision, hash: committed.content_hash };
245
+ }));
246
+ const results = uploaded.filter((item) => item.status === 'fulfilled').map((item) => item.value);
247
+ if (results.some((item) => item.status === 'committed')) saveManifest(projectDir, manifest);
248
+ const uploadFailures = uploaded.map((item, index) => ({ item, id: prepared[index].id })).filter(({ item }) => item.status === 'rejected');
249
+ if (uploadFailures.length) {
250
+ const error = uploadFailures[0].item.reason;
251
+ error.details = { ...(error.details || {}), batch: {
252
+ completed: results,
253
+ 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 })),
254
+ not_started: [],
255
+ } };
256
+ throw error;
197
257
  }
198
258
  return results;
199
259
  }
@@ -202,22 +262,48 @@ async function inspectRemote(projectDir, options = {}) {
202
262
  const requested = options.ids ? new Set(options.ids.map(String)) : null;
203
263
  const entries = Object.values(loadManifest(projectDir).entries)
204
264
  .filter((entry) => !requested || requested.has(String(entry.resource_id)));
205
- const result = [];
206
- for (const entry of entries) {
265
+ return Promise.all(entries.map(async (entry) => {
207
266
  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
267
  const localArchiveHash = checkoutHash(state.local); let status;
209
268
  if (remoteMatches) status = state.modified ? 'local_modified' : 'clean';
210
269
  else if (!state.modified) status = 'remote_changed';
211
270
  else if (filesMatchRemote(state.local, remote)) status = 'committed_unrecorded';
212
271
  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
- }
272
+ return { ...entry, state: status, remote_hash: remote.content_hash, remote_revision: remote.base_revision, remote_etag: remote.etag, local_hash: localArchiveHash };
273
+ }));
274
+ }
275
+ function currentServiceMetadata(entry) { return JSON.parse(readFiles(entry.local_dir)['service.json'].toString('utf8')); }
276
+ function validationCurrent(entry) {
277
+ const value = currentServiceMetadata(entry);
278
+ return value.validation_status === 'passed' && String(value.validated_revision) === String(entry.base_revision)
279
+ && /^[a-f0-9]{64}$/i.test(String(value.validated_hash || ''));
280
+ }
281
+ function syncCheckoutEntry(manifest, id, remote) {
282
+ const entry = manifest.entries[entryKey(id)];
283
+ if (!entry) throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `Custom service ${id} is not checked out.`);
284
+ const state = localState(entry);
285
+ if (state.modified) throw new WorktreeError('LOCAL_CHANGES_PRESENT', `Custom service ${id} changed locally during its remote lifecycle.`);
286
+ applyRemoteEntry(entry, remote, state.local); manifest.entries[entryKey(id)] = entry;
287
+ return entry;
288
+ }
289
+ async function validateCommitted(projectDir, id, config, manifest, options = {}) {
290
+ const entry = manifest.entries[entryKey(id)];
291
+ if (!entry) throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `Custom service ${id} is not checked out.`);
292
+ if (validationCurrent(entry)) return { validation_status: 'passed', revision: entry.base_revision, checkout: null, cached: true };
293
+ const result = await jsonRequest(config, 'POST', `scripts/${encodeURIComponent(id)}/draft/validate`, { revision: entry.base_revision }, options);
294
+ if (!result.checkout) throw new WorktreeError('INCOMPLETE_CHECKOUT_METADATA', 'DraftGo validation response did not include checkout metadata.');
295
+ syncCheckoutEntry(manifest, id, result.checkout);
215
296
  return result;
216
297
  }
298
+ function validationStale(error) {
299
+ return Boolean(error) && error.status === 409 && error.code === 'VALIDATION_FAILED'
300
+ && error.details && error.details.reason === 'validation_stale';
301
+ }
217
302
  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;
303
+ const config = options.config || loadProjectConfig(projectDir); await commit(projectDir, [id], { ...options, config });
304
+ const manifest = loadManifest(projectDir); const result = await validateCommitted(projectDir, id, config, manifest, options);
305
+ if (!result.cached) saveManifest(projectDir, manifest);
306
+ return result;
221
307
  }
222
308
  function parseHandler(handler) {
223
309
  const value = String(handler || '').trim(); if (!value) return undefined;
@@ -228,19 +314,50 @@ function parseHandler(handler) {
228
314
  return { kind: 'manual', name: value };
229
315
  }
230
316
  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`, {
317
+ const config = options.config || loadProjectConfig(projectDir); await commit(projectDir, [id], { ...options, config });
318
+ const manifest = loadManifest(projectDir); const validation = await validateCommitted(projectDir, id, config, manifest, options);
319
+ const entry = manifest.entries[entryKey(id)];
320
+ if (!validation.cached) saveManifest(projectDir, manifest);
321
+ const result = await jsonRequest(config, 'POST', `scripts/${encodeURIComponent(id)}/draft/execute`, {
233
322
  input, selector: parseHandler(options.handler), headers: options.headers, user: options.user,
234
- test_write: Boolean(options.testWrite), side_effect_policy: options.sideEffectPolicy || 'deny',
323
+ test_write: Boolean(options.testWrite), side_effect_policy: options.sideEffectPolicy || 'deny', revision: entry.base_revision,
235
324
  }, options);
325
+ if (result.checkout) { syncCheckoutEntry(manifest, id, result.checkout); saveManifest(projectDir, manifest); }
326
+ return result;
236
327
  }
237
328
  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 });
329
+ const config = options.config || loadProjectConfig(projectDir); const targets = uniqueIDs(ids);
330
+ await commit(projectDir, targets, { ...options, config });
331
+ const manifest = loadManifest(projectDir);
332
+ const validations = await Promise.all(targets.map((id) => validateCommitted(projectDir, id, config, manifest, options)));
333
+ if (validations.some((result) => !result.cached)) saveManifest(projectDir, manifest);
334
+ const published = await Promise.allSettled(targets.map(async (id) => {
335
+ let entry = manifest.entries[entryKey(id)];
336
+ const requestPublish = () => jsonRequest(config, 'POST', `scripts/${encodeURIComponent(id)}/draft/publish`, { revision: entry.base_revision }, options);
337
+ let result;
338
+ try {
339
+ result = await requestPublish();
340
+ } catch (error) {
341
+ if (!validationStale(error)) throw error;
342
+ const validation = await jsonRequest(config, 'POST', `scripts/${encodeURIComponent(id)}/draft/validate`, { revision: entry.base_revision }, options);
343
+ if (!validation.checkout) throw new WorktreeError('INCOMPLETE_CHECKOUT_METADATA', 'DraftGo validation response did not include checkout metadata.');
344
+ entry = syncCheckoutEntry(manifest, id, validation.checkout);
345
+ result = await requestPublish();
346
+ }
347
+ if (!result.checkout) throw new WorktreeError('INCOMPLETE_CHECKOUT_METADATA', 'DraftGo publish response did not include checkout metadata.');
348
+ syncCheckoutEntry(manifest, id, result.checkout); return result;
349
+ }));
350
+ const results = published.filter((item) => item.status === 'fulfilled').map((item) => item.value);
351
+ if (results.length) saveManifest(projectDir, manifest);
352
+ const failures = published.map((item, index) => ({ item, id: targets[index] })).filter(({ item }) => item.status === 'rejected');
353
+ if (failures.length) {
354
+ const error = failures[0].item.reason;
355
+ error.details = { ...(error.details || {}), batch: {
356
+ completed: results, failed: failures.map(({ item, id }) => ({ resource_id: id, status: 'failed', code: item.reason.code || 'PUBLISH_FAILED', message: item.reason.message })), not_started: [],
357
+ } };
358
+ throw error;
242
359
  }
243
360
  return results;
244
361
  }
245
362
 
246
- module.exports = { FILES, checkout, commit, diff, validate, test, publish, inspectRemote, loadManifest, getEntry, metadata, archiveFiles, createArchive, serviceConflicts, showConflict, resolveConflict, parseHandler };
363
+ module.exports = { FILES, checkout, commit, diff, validate, test, publish, inspectRemote, loadManifest, getEntry, metadata, archiveFiles, createArchive, serviceConflicts, showConflict, resolveConflict, parseHandler, validationStale };
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // Environment detection helpers for the local-dev wizard.
3
+ // Environment detection helpers for the local stack setup wizard.
4
4
  // - probePort: TCP probe (used to find existing MySQL/Redis)
5
5
  // - probeHttp: HTTP GET probe with retry (used to wait for the app)
6
6
  // - detectDocker: Find a working `docker` + `docker compose` (or `docker-compose`)
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // Lightweight MySQL helper used by the local-dev wizard to validate
3
+ // Lightweight MySQL helper used by the local stack wizard to validate
4
4
  // credentials and auto-create the project database when missing.
5
5
  //
6
6
  // We avoid taking a JS MySQL driver as a dependency. Instead we shell out
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // Shared local dependencies live outside individual projects. Project Compose
3
+ // Shared local runtime dependencies live outside individual projects. Project Compose
4
4
  // files only start DraftGo itself and connect back to these loopback services.
5
5
  const net = require('net');
6
6
  const os = require('os');
@@ -9,6 +9,8 @@ const IGNORE_ENTRIES = [
9
9
  '.draftgo/token',
10
10
  '.draftgo/worktree/',
11
11
  '.draftgo/conflicts/',
12
+ '.draftgo/api-contract-cache.json',
13
+ '.draftgo/api-contract-cache.json.lock',
12
14
  ];
13
15
 
14
16
  function parseHttpUrl(raw) {
@@ -1,11 +1,9 @@
1
1
  'use strict';
2
2
 
3
- // Thin compatibility layer over src/platforms.js + src/skill.js.
4
- // Each "installer" is just a platform descriptor exposing install/uninstall/
5
- // status methods that delegate to the generic renderer.
3
+ // Public Skill target registry backed by shared platform descriptors.
6
4
 
7
- const { platforms } = require('../platforms');
8
- const skill = require('../skill');
5
+ const { platforms } = require('./platforms');
6
+ const skill = require('./skill');
9
7
 
10
8
  function makeInstaller(p) {
11
9
  return {