draftgo-cli 3.0.48 → 3.0.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,14 @@ const {
15
15
  resourceFileName,
16
16
  safeIdSegment,
17
17
  } = require('./types');
18
- const { streamToFiles, hashFile, copyFileAtomic, writeJsonAtomic, normalizeSha256 } = require('./streams');
18
+ const {
19
+ streamToFiles,
20
+ hashFile,
21
+ copyFileAtomic,
22
+ writeJsonAtomic,
23
+ normalizeSha256,
24
+ tempPathFor,
25
+ } = require('./streams');
19
26
  const {
20
27
  loadManifest,
21
28
  getEntry,
@@ -24,6 +31,7 @@ const {
24
31
  saveManifest,
25
32
  } = require('./manifest');
26
33
  const { validateContentFile } = require('./validate');
34
+ const { inspectEntry, openMetadataSession, sameVersion } = require('./status');
27
35
 
28
36
  const CONFLICT_SCHEMA_VERSION = 1;
29
37
 
@@ -248,36 +256,148 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
248
256
  const session = await createSession(config, options);
249
257
  const manifest = loadManifest(projectDir);
250
258
  const results = [];
259
+ const plans = [];
260
+ const preflightFailures = [];
261
+
262
+ const report = (result) => {
263
+ results.push(result);
264
+ if (typeof options.onStatus === 'function') options.onStatus(result);
265
+ };
251
266
 
267
+ // Resolve and validate every target before the first remote write. This prevents
268
+ // a late local or stale-version error from causing an avoidable partial batch.
252
269
  for (const resourceId of ids) {
253
- const entry = getEntry(manifest, canonical, resourceId);
254
- if (!entry) {
255
- throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `${canonical} ${resourceId} is not checked out.`);
256
- }
257
- if (entry.server !== config.server) {
258
- throw new WorktreeError('CHECKOUT_SERVER_MISMATCH', 'Checkout belongs to a different DraftGo server.');
270
+ try {
271
+ const entry = getEntry(manifest, canonical, resourceId);
272
+ if (!entry) {
273
+ throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `${canonical} ${resourceId} is not checked out.`);
274
+ }
275
+ if (entry.server !== config.server) {
276
+ throw new WorktreeError('CHECKOUT_SERVER_MISMATCH', 'Checkout belongs to a different DraftGo server.');
277
+ }
278
+ if (unresolvedConflict(projectDir, canonical, resourceId)) {
279
+ throw new WorktreeError(
280
+ 'UNRESOLVED_CONFLICT',
281
+ `${canonical} ${resourceId} has an unresolved conflict; resolve it before committing.`,
282
+ );
283
+ }
284
+
285
+ const localPath = absolutePath(projectDir, entry.local_path);
286
+ const current = await hashFile(localPath);
287
+ const basePath = absolutePath(projectDir, entry.base_path);
288
+ const base = await hashFile(basePath);
289
+ if (base.hash !== entry.base_hash) {
290
+ throw new WorktreeError(
291
+ 'BASE_HASH_MISMATCH',
292
+ `${canonical} ${resourceId} base hash differs from its manifest; run draftgo check --remote.`,
293
+ { manifest_hash: entry.base_hash, base_hash: base.hash },
294
+ );
295
+ }
296
+ if (current.hash !== entry.base_hash) validateContentFile(localPath, entry.content_type);
297
+
298
+ const fresh = await backend.resolveMetadata(config, canonical, resourceId, {
299
+ ...options,
300
+ ...session,
301
+ clientInitialized: true,
302
+ });
303
+ assertMetadataIdentity(fresh, canonical, resourceId);
304
+ const remoteMatchesBase = fresh.content_hash === entry.base_hash && sameVersion(entry, fresh);
305
+ if (!remoteMatchesBase) {
306
+ const state = await inspectEntry(projectDir, entry, fresh);
307
+ throw new WorktreeError(
308
+ state.local_matches_remote ? 'COMMITTED_UNRECORDED' : 'REMOTE_VERSION_CHANGED',
309
+ state.local_matches_remote
310
+ ? `${canonical} ${resourceId} already equals remote; run ${state.recommendation}.`
311
+ : `${canonical} ${resourceId} changed remotely; inspect with draftgo check --remote before committing.`,
312
+ state,
313
+ );
314
+ }
315
+ plans.push({ resourceId, entry, localPath, current, fresh });
316
+ } catch (error) {
317
+ if (error.code === 'REMOTE_VERSION_CHANGED') {
318
+ const entry = getEntry(manifest, canonical, resourceId);
319
+ try {
320
+ const conflict = await writeConflict(projectDir, entry, error, config, backend, options, session);
321
+ error = new WorktreeError(
322
+ 'RESOURCE_VERSION_CONFLICT',
323
+ `${canonical} ${resourceId} changed remotely; conflict materials were preserved during preflight.`,
324
+ conflict,
325
+ );
326
+ } catch (conflictError) {
327
+ error = conflictError;
328
+ }
329
+ }
330
+ preflightFailures.push({
331
+ resource_type: canonical,
332
+ resource_id: resourceId,
333
+ status: 'failed',
334
+ phase: 'preflight',
335
+ code: error.code || 'PREFLIGHT_FAILED',
336
+ message: error.message,
337
+ details: error.details || {},
338
+ });
259
339
  }
260
- if (unresolvedConflict(projectDir, canonical, resourceId)) {
261
- throw new WorktreeError(
262
- 'UNRESOLVED_CONFLICT',
263
- `${canonical} ${resourceId} has an unresolved conflict; resolve it before committing.`,
340
+ }
341
+
342
+ if (preflightFailures.length) {
343
+ const failedIds = new Set(preflightFailures.map((item) => String(item.resource_id)));
344
+ const notStarted = ids.filter((id) => !failedIds.has(String(id))).map((resourceId) => ({
345
+ resource_type: canonical,
346
+ resource_id: resourceId,
347
+ status: 'not_started',
348
+ phase: 'preflight',
349
+ }));
350
+ for (const result of [...preflightFailures, ...notStarted]) report(result);
351
+ if (ids.length === 1 && preflightFailures.length === 1) {
352
+ const original = new WorktreeError(
353
+ preflightFailures[0].code,
354
+ preflightFailures[0].message,
355
+ preflightFailures[0].details,
264
356
  );
357
+ throw original;
265
358
  }
359
+ throw new WorktreeError(
360
+ 'BATCH_PREFLIGHT_FAILED',
361
+ `Commit preflight failed for ${preflightFailures.length} resource(s); no remote content was changed.`,
362
+ { completed: [], failed: preflightFailures, not_started: notStarted },
363
+ );
364
+ }
266
365
 
267
- const localPath = absolutePath(projectDir, entry.local_path);
268
- const current = await hashFile(localPath);
366
+ function throwBatchFailure(error, index, resourceId, remoteChangePossible = false) {
367
+ const failed = {
368
+ resource_type: canonical,
369
+ resource_id: resourceId,
370
+ status: 'failed',
371
+ phase: 'upload',
372
+ code: error.code || 'COMMIT_FAILED',
373
+ message: error.message,
374
+ remote_change_possible: remoteChangePossible,
375
+ };
376
+ const notStarted = plans.slice(index + 1).map((plan) => ({
377
+ resource_type: canonical,
378
+ resource_id: plan.resourceId,
379
+ status: 'not_started',
380
+ phase: 'upload',
381
+ }));
382
+ report(failed);
383
+ for (const item of notStarted) report(item);
384
+ error.details = {
385
+ ...(error.details || {}),
386
+ batch: {
387
+ completed: results.filter((item) => ['committed', 'unchanged'].includes(item.status)),
388
+ failed: [failed],
389
+ not_started: notStarted,
390
+ },
391
+ };
392
+ throw error;
393
+ }
394
+
395
+ for (let index = 0; index < plans.length; index += 1) {
396
+ const { resourceId, entry, localPath, current, fresh } = plans[index];
269
397
  if (current.hash === entry.base_hash) {
270
- results.push({ resource_type: canonical, resource_id: resourceId, status: 'unchanged', hash: current.hash });
398
+ report({ resource_type: canonical, resource_id: resourceId, status: 'unchanged', hash: current.hash });
271
399
  continue;
272
400
  }
273
- validateContentFile(localPath, entry.content_type);
274
-
275
- const fresh = await backend.resolveMetadata(config, canonical, resourceId, {
276
- ...options,
277
- ...session,
278
- clientInitialized: true,
279
- });
280
- assertMetadataIdentity(fresh, canonical, resourceId);
281
401
  const commitMetadata = {
282
402
  ...fresh,
283
403
  content_type: entry.content_type,
@@ -290,55 +410,147 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
290
410
  try {
291
411
  responsePayload = await backend.commit(config, commitMetadata, localPath, current, options);
292
412
  } catch (error) {
293
- if (!isVersionConflict(error)) throw error;
294
- const conflict = await writeConflict(projectDir, entry, error, config, backend, options, session);
295
- throw new WorktreeError(
296
- 'RESOURCE_VERSION_CONFLICT',
297
- `${canonical} ${resourceId} changed remotely; conflict materials were preserved.`,
298
- conflict,
299
- );
413
+ let failure = error;
414
+ const versionConflict = isVersionConflict(error);
415
+ if (versionConflict) {
416
+ const conflict = await writeConflict(projectDir, entry, error, config, backend, options, session);
417
+ failure = new WorktreeError(
418
+ 'RESOURCE_VERSION_CONFLICT',
419
+ `${canonical} ${resourceId} changed remotely; conflict materials were preserved.`,
420
+ conflict,
421
+ );
422
+ }
423
+ throwBatchFailure(failure, index, resourceId, !versionConflict);
300
424
  }
301
425
 
302
- let committed = commitVersion(normalizeCommitPayload(responsePayload));
303
- if (!committed.hash || (committed.version == null && committed.revision == null)) {
304
- const confirmed = await backend.resolveMetadata(config, canonical, resourceId, {
305
- ...options,
306
- ...session,
307
- clientInitialized: true,
426
+ try {
427
+ let committed = commitVersion(normalizeCommitPayload(responsePayload));
428
+ if (!committed.hash || (committed.version == null && committed.revision == null)) {
429
+ const confirmed = await backend.resolveMetadata(config, canonical, resourceId, {
430
+ ...options,
431
+ ...session,
432
+ clientInitialized: true,
433
+ });
434
+ assertMetadataIdentity(confirmed, canonical, resourceId);
435
+ committed = {
436
+ version: confirmed.base_version,
437
+ revision: confirmed.base_revision,
438
+ etag: confirmed.etag,
439
+ hash: confirmed.content_hash,
440
+ updated_at: confirmed.updated_at,
441
+ updated_by: confirmed.updated_by,
442
+ };
443
+ }
444
+ if (committed.hash && committed.hash !== current.hash) {
445
+ throw new WorktreeError(
446
+ 'HASH_MISMATCH',
447
+ 'DraftGo commit response hash differs from the uploaded raw bytes; manifest was not advanced.',
448
+ { local_hash: current.hash, remote_hash: committed.hash },
449
+ );
450
+ }
451
+
452
+ await copyFileAtomic(localPath, absolutePath(projectDir, entry.base_path), { expectedHash: current.hash });
453
+ entry.base_hash = current.hash;
454
+ entry.content_size = current.size;
455
+ entry.base_version = committed.version;
456
+ entry.base_revision = committed.revision;
457
+ entry.base_etag = committed.etag;
458
+ entry.updated_at = committed.updated_at;
459
+ entry.updated_by = committed.updated_by;
460
+ entry.committed_at = new Date().toISOString();
461
+ await saveManifest(projectDir, manifest);
462
+ report({
463
+ resource_type: canonical,
464
+ resource_id: resourceId,
465
+ status: 'committed',
466
+ hash: current.hash,
467
+ base_version: entry.base_version,
468
+ base_revision: entry.base_revision,
308
469
  });
309
- assertMetadataIdentity(confirmed, canonical, resourceId);
310
- committed = {
311
- version: confirmed.base_version,
312
- revision: confirmed.base_revision,
313
- etag: confirmed.etag,
314
- hash: confirmed.content_hash,
315
- updated_at: confirmed.updated_at,
316
- updated_by: confirmed.updated_by,
317
- };
470
+ } catch (error) {
471
+ throwBatchFailure(error, index, resourceId, true);
472
+ }
473
+ }
474
+ return results;
475
+ }
476
+
477
+ async function reconcileResources(projectDir, resourceType, resourceIds, options = {}) {
478
+ const canonical = canonicalResourceType(resourceType);
479
+ const ids = ensureIds(resourceIds);
480
+ const config = options.config || loadProjectConfig(projectDir);
481
+ const backend = backendFor(options);
482
+ const manifest = loadManifest(projectDir);
483
+ const session = options.backend && typeof options.backend.resolveMetadata === 'function'
484
+ ? { client: options.client || {}, tools: options.tools || [] }
485
+ : await openMetadataSession(config, options);
486
+ const results = [];
487
+
488
+ for (const resourceId of ids) {
489
+ const entry = getEntry(manifest, canonical, resourceId);
490
+ if (!entry) throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `${canonical} ${resourceId} is not checked out.`);
491
+ if (entry.server !== config.server) {
492
+ throw new WorktreeError('CHECKOUT_SERVER_MISMATCH', 'Checkout belongs to a different DraftGo server.');
318
493
  }
319
- if (committed.hash && committed.hash !== current.hash) {
494
+ if (unresolvedConflict(projectDir, canonical, resourceId)) {
320
495
  throw new WorktreeError(
321
- 'HASH_MISMATCH',
322
- 'DraftGo commit response hash differs from the uploaded raw bytes; manifest was not advanced.',
323
- { local_hash: current.hash, remote_hash: committed.hash },
496
+ 'UNRESOLVED_CONFLICT',
497
+ `${canonical} ${resourceId} has an unresolved conflict; use conflict resolve instead of reconcile.`,
498
+ );
499
+ }
500
+ const remote = await backend.resolveMetadata(config, canonical, resourceId, {
501
+ ...options, ...session, clientInitialized: true,
502
+ });
503
+ assertMetadataIdentity(remote, canonical, resourceId);
504
+ const status = await inspectEntry(projectDir, entry, remote);
505
+ if (!status.local_matches_remote) {
506
+ throw new WorktreeError(
507
+ 'RECONCILE_UNSAFE',
508
+ `${canonical} ${resourceId} local content does not equal remote; refusing to advance metadata.`,
509
+ status,
510
+ );
511
+ }
512
+ if (entry.content_type !== remote.content_type || entry.file_extension !== remote.file_extension) {
513
+ throw new WorktreeError(
514
+ 'RECONCILE_FORMAT_CHANGED',
515
+ `${canonical} ${resourceId} remote content format changed; use checkout after preserving local work.`,
324
516
  );
325
517
  }
326
518
 
327
- await copyFileAtomic(localPath, absolutePath(projectDir, entry.base_path), { expectedHash: current.hash });
328
- entry.base_hash = current.hash;
329
- entry.content_size = current.size;
330
- entry.base_version = committed.version;
331
- entry.base_revision = committed.revision;
332
- entry.base_etag = committed.etag;
333
- entry.updated_at = committed.updated_at;
334
- entry.updated_by = committed.updated_by;
335
- entry.committed_at = new Date().toISOString();
519
+ const response = await backend.download(config, remote, options);
520
+ const basePath = absolutePath(projectDir, entry.base_path);
521
+ const verifiedRemote = tempPathFor(basePath);
522
+ let verified;
523
+ try {
524
+ verified = await streamToFiles(response.body, [verifiedRemote], {
525
+ expectedHash: remote.content_hash,
526
+ expectedSize: remote.content_size,
527
+ });
528
+ const localAfterDownload = await hashFile(absolutePath(projectDir, entry.local_path));
529
+ if (localAfterDownload.hash !== verified.hash) {
530
+ throw new WorktreeError(
531
+ 'RECONCILE_LOCAL_CHANGED',
532
+ `${canonical} ${resourceId} local content changed during reconcile; manifest was not advanced.`,
533
+ { local_hash: localAfterDownload.hash, remote_hash: verified.hash },
534
+ );
535
+ }
536
+ await copyFileAtomic(verifiedRemote, basePath, { expectedHash: verified.hash });
537
+ } finally {
538
+ await fs.promises.rm(verifiedRemote, { force: true }).catch(() => {});
539
+ }
540
+ entry.base_hash = verified.hash;
541
+ entry.content_size = verified.size;
542
+ entry.base_version = remote.base_version;
543
+ entry.base_revision = remote.base_revision;
544
+ entry.base_etag = remote.etag;
545
+ entry.updated_at = remote.updated_at;
546
+ entry.updated_by = remote.updated_by;
547
+ entry.reconciled_at = new Date().toISOString();
336
548
  await saveManifest(projectDir, manifest);
337
549
  results.push({
338
550
  resource_type: canonical,
339
551
  resource_id: resourceId,
340
- status: 'committed',
341
- hash: current.hash,
552
+ status: 'reconciled',
553
+ hash: verified.hash,
342
554
  base_version: entry.base_version,
343
555
  base_revision: entry.base_revision,
344
556
  });
@@ -454,6 +666,7 @@ module.exports = {
454
666
  conflictPaths,
455
667
  checkoutResources,
456
668
  commitResources,
669
+ reconcileResources,
457
670
  diffResource,
458
671
  listConflicts,
459
672
  showConflict,
@@ -0,0 +1,122 @@
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 = Object.values(manifest.entries);
105
+ return Promise.all(entries.map(async (entry) => {
106
+ const remote = await backend.resolveMetadata(config, entry.resource_type, entry.resource_id, {
107
+ ...options,
108
+ ...session,
109
+ clientInitialized: true,
110
+ });
111
+ return inspectEntry(projectDir, entry, remote);
112
+ }));
113
+ }
114
+
115
+ module.exports = {
116
+ versionValue,
117
+ sameVersion,
118
+ classify,
119
+ inspectEntry,
120
+ openMetadataSession,
121
+ inspectRemoteCheckouts,
122
+ };