sdd-mcp-server 4.0.0 → 5.0.0

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 (73) hide show
  1. package/README.md +18 -35
  2. package/dist/adapters/cli/SDDToolAdapter.d.ts +2 -5
  3. package/dist/adapters/cli/SDDToolAdapter.js +48 -92
  4. package/dist/adapters/cli/SDDToolAdapter.js.map +1 -1
  5. package/dist/application/services/ContextCompactionService.d.ts +10 -3
  6. package/dist/application/services/ContextCompactionService.js +139 -35
  7. package/dist/application/services/ContextCompactionService.js.map +1 -1
  8. package/dist/application/services/ProjectService.js +3 -3
  9. package/dist/application/services/ProjectService.js.map +1 -1
  10. package/dist/application/services/SpecPathResolver.js +1 -1
  11. package/dist/application/services/SpecPathResolver.js.map +1 -1
  12. package/dist/application/services/WorkflowEngineService.d.ts +160 -50
  13. package/dist/application/services/WorkflowEngineService.js +1404 -429
  14. package/dist/application/services/WorkflowEngineService.js.map +1 -1
  15. package/dist/application/services/WorkflowErrors.d.ts +16 -0
  16. package/dist/application/services/WorkflowErrors.js +53 -0
  17. package/dist/application/services/WorkflowErrors.js.map +1 -0
  18. package/dist/application/services/WorkflowValidationService.d.ts +25 -46
  19. package/dist/application/services/WorkflowValidationService.js +284 -627
  20. package/dist/application/services/WorkflowValidationService.js.map +1 -1
  21. package/dist/cli/install-skills.js.map +1 -1
  22. package/dist/cli/install-target.d.ts +4 -1
  23. package/dist/cli/install-target.js +4 -0
  24. package/dist/cli/install-target.js.map +1 -1
  25. package/dist/cli/tool-support/claude-code.js +7 -3
  26. package/dist/cli/tool-support/claude-code.js.map +1 -1
  27. package/dist/cli/tool-support/codex.js +6 -2
  28. package/dist/cli/tool-support/codex.js.map +1 -1
  29. package/dist/cli/tool-support/mcp-registration.d.ts +22 -0
  30. package/dist/cli/tool-support/mcp-registration.js +275 -0
  31. package/dist/cli/tool-support/mcp-registration.js.map +1 -0
  32. package/dist/cli/tool-support/omp.js +6 -2
  33. package/dist/cli/tool-support/omp.js.map +1 -1
  34. package/dist/cli/tool-support/root-guidance.js +2 -2
  35. package/dist/cli/tool-support/root-guidance.js.map +1 -1
  36. package/dist/cli/tool-support/target-installer.d.ts +2 -2
  37. package/dist/cli/tool-support/target-installer.js +9 -3
  38. package/dist/cli/tool-support/target-installer.js.map +1 -1
  39. package/dist/cli/utils/preserving-writer.d.ts +35 -1
  40. package/dist/cli/utils/preserving-writer.js +479 -108
  41. package/dist/cli/utils/preserving-writer.js.map +1 -1
  42. package/dist/domain/types.d.ts +52 -7
  43. package/dist/domain/types.js +5 -4
  44. package/dist/domain/types.js.map +1 -1
  45. package/dist/infrastructure/mcp/MCPServer.js +13 -13
  46. package/dist/infrastructure/mcp/MCPServer.js.map +1 -1
  47. package/dist/infrastructure/mcp/ToolRegistry.d.ts +5 -1
  48. package/dist/infrastructure/mcp/ToolRegistry.js +11 -4
  49. package/dist/infrastructure/mcp/ToolRegistry.js.map +1 -1
  50. package/dist/infrastructure/mcp/sddToolDefinitions.js +76 -90
  51. package/dist/infrastructure/mcp/sddToolDefinitions.js.map +1 -1
  52. package/dist/infrastructure/schemas/project.schema.d.ts +2 -2
  53. package/dist/infrastructure/schemas/project.schema.js +2 -2
  54. package/dist/infrastructure/schemas/project.schema.js.map +1 -1
  55. package/dist/shared/version.d.ts +3 -0
  56. package/dist/shared/version.js +4 -0
  57. package/dist/shared/version.js.map +1 -0
  58. package/dist/utils/atomicWrite.js +20 -5
  59. package/dist/utils/atomicWrite.js.map +1 -1
  60. package/dist/utils/withFilesystemLock.d.ts +22 -0
  61. package/dist/utils/withFilesystemLock.js +219 -0
  62. package/dist/utils/withFilesystemLock.js.map +1 -0
  63. package/package.json +4 -2
  64. package/skills/sdd-design/REFERENCE.md +16 -0
  65. package/skills/sdd-design/SKILL.md +22 -13
  66. package/skills/sdd-implement/REFERENCE.md +4 -0
  67. package/skills/sdd-implement/SKILL.md +20 -16
  68. package/skills/sdd-requirements/REFERENCE.md +15 -7
  69. package/skills/sdd-requirements/SKILL.md +30 -22
  70. package/skills/sdd-tasks/REFERENCE.md +9 -9
  71. package/skills/sdd-tasks/SKILL.md +27 -15
  72. package/templates/CLAUDE.md +6 -12
  73. package/templates/codex-AGENTS.md +7 -9
@@ -1,11 +1,11 @@
1
1
  import * as crypto from 'crypto';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
- import { setTimeout as delay } from 'timers/promises';
4
+ import { INSTALL_MANIFEST_SCHEMA_VERSION, INSTALL_RENDERER_VERSION, PACKAGE_VERSION } from '../../shared/version.js';
5
5
  import { atomicWriteFile } from '../../utils/atomicWrite.js';
6
- const MANIFEST_SCHEMA_VERSION = 1;
7
- const RENDERER_VERSION = 4;
8
- const PACKAGE_VERSION = '4.0.0';
6
+ import { withFilesystemLock } from '../../utils/withFilesystemLock.js';
7
+ import { getTargetPolicy, } from '../install-target.js';
8
+ import { registerRuntimeLocked, } from '../tool-support/mcp-registration.js';
9
9
  /** Explicit v3.5.1 package-owned files which v4 no longer renders. */
10
10
  export const LEGACY_V351_TOMBSTONES = {
11
11
  'claude-code': ['.claude/rules/git-workflow.md', '.claude/rules/sdd-workflow.md'],
@@ -50,16 +50,92 @@ export class PreservingWriter {
50
50
  selectedComponents = new Map();
51
51
  refreshTargets = new Set();
52
52
  pendingShared = new Map();
53
+ runtimePaths = new Map();
54
+ runtimeResults = new Map();
55
+ activeLeaseAssert;
56
+ pendingWrites = new Map();
57
+ committedTargets = new Set();
58
+ preservedModifiedFiles = new Map();
53
59
  backupStamp;
54
60
  constructor(projectRoot) {
55
61
  this.projectRoot = projectRoot;
56
62
  }
57
63
  beginTarget(target, profile, components, refreshGenerated = false) {
58
64
  this.generated.set(target, new Map());
65
+ this.preservedModifiedFiles.set(target, new Set());
66
+ this.committedTargets.delete(target);
67
+ this.runtimeResults.delete(target);
59
68
  this.profiles.set(target, profile);
60
69
  this.selectedComponents.set(target, new Set([...components, 'root']));
61
70
  if (refreshGenerated)
62
71
  this.refreshTargets.add(target);
72
+ else
73
+ this.refreshTargets.delete(target);
74
+ }
75
+ async withInstallLock(action) {
76
+ if (this.activeLeaseAssert)
77
+ return action();
78
+ const lockPath = path.join(this.projectRoot, '.sdd-mcp', 'install.lock');
79
+ this.validateDestination(lockPath);
80
+ await fs.promises.mkdir(path.dirname(lockPath), { recursive: true });
81
+ this.validateDestination(lockPath);
82
+ const committedBefore = new Set(this.committedTargets);
83
+ let completed = false;
84
+ let value;
85
+ try {
86
+ return await withFilesystemLock(lockPath, async (lease) => {
87
+ this.activeLeaseAssert = lease.assertHeld;
88
+ try {
89
+ value = await action();
90
+ completed = true;
91
+ return value;
92
+ }
93
+ finally {
94
+ this.activeLeaseAssert = undefined;
95
+ }
96
+ });
97
+ }
98
+ catch (error) {
99
+ const committedDuringAction = [...this.committedTargets]
100
+ .filter((target) => !committedBefore.has(target));
101
+ if (!completed || committedDuringAction.length === 0)
102
+ throw error;
103
+ const warning = `Installation committed, but install lock release reported an error: ${error instanceof Error ? error.message : String(error)}`;
104
+ for (const target of committedDuringAction) {
105
+ const result = this.runtimeResults.get(target);
106
+ if (result && !result.warnings.includes(warning))
107
+ result.warnings.push(warning);
108
+ }
109
+ if (value && typeof value === 'object' && 'warnings' in value) {
110
+ const warnings = value.warnings;
111
+ if (Array.isArray(warnings) && !warnings.includes(warning))
112
+ warnings.push(warning);
113
+ }
114
+ return value;
115
+ }
116
+ }
117
+ requireRuntimeRegistration(target, paths) {
118
+ this.runtimePaths.set(target, paths);
119
+ }
120
+ takeRuntimeResult(target) {
121
+ return this.runtimeResults.get(target) ?? { installed: [], skipped: [], warnings: [] };
122
+ }
123
+ async rollbackUncommitted(target, assertHeld = this.activeLeaseAssert) {
124
+ const writes = this.pendingWrites.get(target);
125
+ if (!writes)
126
+ return;
127
+ const leaseAssert = assertHeld ?? (async () => undefined);
128
+ for (const [filePath, snapshot] of [...writes].reverse()) {
129
+ if (snapshot.prior === null) {
130
+ if (snapshot.next !== null) {
131
+ await this.removeBytesCas(filePath, snapshot.next, leaseAssert);
132
+ }
133
+ }
134
+ else {
135
+ await this.replaceBytesCas(filePath, snapshot.next, snapshot.prior, leaseAssert);
136
+ }
137
+ }
138
+ this.pendingWrites.delete(target);
63
139
  }
64
140
  async writeManaged(target, component, name, filePath, content) {
65
141
  this.validateDestination(filePath);
@@ -69,7 +145,8 @@ export class PreservingWriter {
69
145
  const exists = await pathExists(filePath);
70
146
  if (component === 'steering') {
71
147
  if (!exists) {
72
- await atomicWriteFile(filePath, content);
148
+ this.trackPendingWrite(target, filePath, null, Buffer.from(content));
149
+ await this.replaceManagedBytesCas(filePath, null, Buffer.from(content));
73
150
  this.pendingShared.set(relative, { sha256: desiredHash, component: 'steering', mutable: true });
74
151
  return { outcome: 'installed' };
75
152
  }
@@ -77,25 +154,38 @@ export class PreservingWriter {
77
154
  this.pendingShared.set(relative, { sha256: existingHash, component: 'steering', mutable: true });
78
155
  return { outcome: 'skipped' };
79
156
  }
80
- this.generatedFor(target).set(relative, { sha256: desiredHash, component });
157
+ const generated = this.generatedFor(target);
81
158
  const prior = manifest.targets[target]?.files[relative];
82
159
  if (!exists) {
83
- await atomicWriteFile(filePath, content);
160
+ generated.set(relative, { sha256: desiredHash, component });
161
+ this.trackPendingWrite(target, filePath, null, Buffer.from(content));
162
+ await this.replaceManagedBytesCas(filePath, null, Buffer.from(content));
84
163
  return { outcome: 'installed' };
85
164
  }
86
165
  const existing = await fs.promises.readFile(filePath, 'utf8');
87
166
  const existingHash = sha256(existing);
88
- if (existingHash === desiredHash)
167
+ if (existingHash === desiredHash) {
168
+ generated.set(relative, { sha256: desiredHash, component });
89
169
  return { outcome: 'skipped' };
170
+ }
90
171
  if (this.refreshTargets.has(target)) {
172
+ generated.set(relative, { sha256: desiredHash, component });
173
+ this.trackPendingWrite(target, filePath, Buffer.from(existing), Buffer.from(content));
174
+ await this.activeLeaseAssert?.();
91
175
  await this.backup(target, relative, filePath);
92
- await atomicWriteFile(filePath, content);
176
+ await this.replaceManagedBytesCas(filePath, Buffer.from(existing), Buffer.from(content));
93
177
  return { outcome: 'installed' };
94
178
  }
95
179
  if (prior && existingHash === prior.sha256) {
96
- await atomicWriteFile(filePath, content);
180
+ generated.set(relative, { sha256: desiredHash, component });
181
+ this.trackPendingWrite(target, filePath, Buffer.from(existing), Buffer.from(content));
182
+ await this.replaceManagedBytesCas(filePath, Buffer.from(existing), Buffer.from(content));
97
183
  return { outcome: 'installed' };
98
184
  }
185
+ if (prior) {
186
+ generated.set(relative, prior);
187
+ this.preservedModifiedFiles.get(target)?.add(relative);
188
+ }
99
189
  return {
100
190
  outcome: 'skipped',
101
191
  conflict: {
@@ -106,68 +196,167 @@ export class PreservingWriter {
106
196
  },
107
197
  };
108
198
  }
199
+ async installRuntimeRegistration(target, paths = getTargetPolicy(target).defaultPaths) {
200
+ return this.withInstallLock(async () => {
201
+ const assertHeld = this.requireActiveLease();
202
+ const manifestSnapshot = await this.readManifestSnapshot();
203
+ const manifest = manifestSnapshot.manifest;
204
+ const previous = manifest.targets[target];
205
+ const runtime = await registerRuntimeLocked(this.projectRoot, target, paths, previous?.registrations[0], assertHeld);
206
+ const latest = manifest;
207
+ latest.targets[target] = {
208
+ profile: previous?.profile ?? 'lean',
209
+ packageVersion: PACKAGE_VERSION,
210
+ rendererVersion: INSTALL_RENDERER_VERSION,
211
+ files: previous?.files ?? {},
212
+ registrations: [runtime.registration],
213
+ };
214
+ const priorManifestBytes = manifestSnapshot.bytes;
215
+ const nextManifestBytes = Buffer.from(`${JSON.stringify(latest, null, 2)}\n`);
216
+ try {
217
+ await runtime.verify();
218
+ await this.replaceBytesCas(this.manifestPath, priorManifestBytes, nextManifestBytes, assertHeld);
219
+ }
220
+ catch (error) {
221
+ const currentManifestBytes = await readOptionalBytes(this.manifestPath);
222
+ if (buffersEqual(currentManifestBytes, nextManifestBytes)) {
223
+ this.markTargetCommitted(target);
224
+ return {
225
+ installed: runtime.installed,
226
+ skipped: runtime.skipped,
227
+ warnings: [`Runtime registration committed, but manifest write reported an error: ${error instanceof Error ? error.message : String(error)}`],
228
+ };
229
+ }
230
+ if (buffersEqual(currentManifestBytes, priorManifestBytes)) {
231
+ await runtime.rollback();
232
+ throw error;
233
+ }
234
+ throw new Error('Install manifest changed to unknown bytes; runtime partials were preserved');
235
+ }
236
+ this.markTargetCommitted(target);
237
+ return { installed: runtime.installed, skipped: runtime.skipped, warnings: [] };
238
+ });
239
+ }
109
240
  async finalizeTarget(target) {
110
241
  this.validateDestination(this.manifestPath);
111
- return withManifestLock(this.projectRoot, async () => {
112
- const conflicts = [];
113
- const manifest = await this.readManifest();
114
- const previous = manifest.targets[target]?.files ?? {};
115
- const generated = this.generatedFor(target);
116
- const selected = this.selectedComponents.get(target) ?? new Set();
117
- for (const [relative, record] of Object.entries(previous)) {
118
- if (generated.has(relative) || !selected.has(record.component))
242
+ return this.withInstallLock(async () => this.finalizeTargetLocked(target, this.requireActiveLease()));
243
+ }
244
+ async finalizeTargetLocked(target, assertHeld) {
245
+ try {
246
+ return await this.commitTargetLocked(target, assertHeld);
247
+ }
248
+ catch (error) {
249
+ await this.rollbackUncommitted(target, assertHeld);
250
+ throw error;
251
+ }
252
+ }
253
+ async commitTargetLocked(target, assertHeld) {
254
+ const conflicts = [];
255
+ const manifestSnapshot = await this.readManifestSnapshot();
256
+ const manifest = manifestSnapshot.manifest;
257
+ const previousTarget = manifest.targets[target];
258
+ const previous = previousTarget?.files ?? {};
259
+ const generated = this.generatedFor(target);
260
+ const selected = this.selectedComponents.get(target) ?? new Set();
261
+ for (const [relative, record] of Object.entries(previous)) {
262
+ if (generated.has(relative) || !selected.has(record.component))
263
+ continue;
264
+ const destination = path.join(this.projectRoot, relative);
265
+ if (!await pathExists(destination))
266
+ continue;
267
+ this.validateDestination(destination);
268
+ const currentBytes = await fs.promises.readFile(destination);
269
+ const currentHash = sha256(currentBytes);
270
+ if (currentHash !== record.sha256 && !this.refreshTargets.has(target)) {
271
+ conflicts.push({
272
+ component: record.component,
273
+ name: path.basename(relative),
274
+ path: destination,
275
+ reason: 'obsolete-modified',
276
+ });
277
+ continue;
278
+ }
279
+ this.trackPendingWrite(target, destination, currentBytes, null);
280
+ await assertHeld();
281
+ await this.backup(target, relative, destination);
282
+ await this.removeBytesCas(destination, currentBytes, assertHeld);
283
+ }
284
+ if (this.refreshTargets.has(target)) {
285
+ for (const relative of LEGACY_V351_TOMBSTONES[target]) {
286
+ if (generated.has(relative))
119
287
  continue;
120
288
  const destination = path.join(this.projectRoot, relative);
121
289
  if (!await pathExists(destination))
122
290
  continue;
123
291
  this.validateDestination(destination);
124
- const currentHash = sha256(await fs.promises.readFile(destination));
125
- if (currentHash !== record.sha256 && !this.refreshTargets.has(target)) {
126
- conflicts.push({
127
- component: record.component,
128
- name: path.basename(relative),
129
- path: destination,
130
- reason: 'obsolete-modified',
131
- });
132
- continue;
133
- }
292
+ const currentBytes = await fs.promises.readFile(destination);
293
+ this.trackPendingWrite(target, destination, currentBytes, null);
294
+ await assertHeld();
134
295
  await this.backup(target, relative, destination);
135
- await fs.promises.rm(destination, { force: true });
296
+ await this.removeBytesCas(destination, currentBytes, assertHeld);
136
297
  }
137
- if (this.refreshTargets.has(target)) {
138
- for (const relative of LEGACY_V351_TOMBSTONES[target]) {
139
- if (generated.has(relative))
140
- continue;
141
- const destination = path.join(this.projectRoot, relative);
142
- if (!await pathExists(destination))
143
- continue;
144
- this.validateDestination(destination);
145
- await this.backup(target, relative, destination);
146
- await fs.promises.rm(destination, { force: true });
147
- }
298
+ }
299
+ const runtimePaths = this.runtimePaths.get(target) ?? getTargetPolicy(target).defaultPaths;
300
+ const runtime = await registerRuntimeLocked(this.projectRoot, target, runtimePaths, previousTarget?.registrations[0], assertHeld);
301
+ const retainedUnselected = Object.fromEntries(Object.entries(previous).filter(([, record]) => !selected.has(record.component)));
302
+ const latest = manifest;
303
+ latest.targets[target] = {
304
+ profile: this.profiles.get(target) ?? 'lean',
305
+ packageVersion: PACKAGE_VERSION,
306
+ rendererVersion: INSTALL_RENDERER_VERSION,
307
+ files: { ...retainedUnselected, ...Object.fromEntries(generated) },
308
+ registrations: [runtime.registration],
309
+ };
310
+ Object.assign(latest.shared, Object.fromEntries(this.pendingShared));
311
+ const priorManifestBytes = manifestSnapshot.bytes;
312
+ const nextManifestBytes = Buffer.from(`${JSON.stringify(latest, null, 2)}\n`);
313
+ try {
314
+ await this.verifyPendingOwnership(target, generated, assertHeld);
315
+ await runtime.verify();
316
+ await this.replaceBytesCas(this.manifestPath, priorManifestBytes, nextManifestBytes, assertHeld);
317
+ }
318
+ catch (error) {
319
+ const currentManifestBytes = await readOptionalBytes(this.manifestPath);
320
+ if (buffersEqual(currentManifestBytes, nextManifestBytes)) {
321
+ this.runtimeResults.set(target, {
322
+ installed: runtime.installed,
323
+ skipped: runtime.skipped,
324
+ warnings: [`Installation committed, but manifest write reported an error: ${error instanceof Error ? error.message : String(error)}`],
325
+ });
326
+ this.markTargetCommitted(target);
327
+ return conflicts;
148
328
  }
149
- const latest = await this.readManifest();
150
- latest.targets[target] = {
151
- profile: this.profiles.get(target) ?? 'lean',
152
- packageVersion: PACKAGE_VERSION,
153
- rendererVersion: RENDERER_VERSION,
154
- files: Object.fromEntries(generated),
155
- };
156
- Object.assign(latest.shared, Object.fromEntries(this.pendingShared));
157
- await atomicWriteFile(this.manifestPath, `${JSON.stringify(latest, null, 2)}\n`);
158
- return conflicts;
329
+ if (buffersEqual(currentManifestBytes, priorManifestBytes)) {
330
+ await runtime.rollback();
331
+ throw error;
332
+ }
333
+ this.pendingWrites.delete(target);
334
+ throw new Error('Install manifest changed to unknown bytes; installer partials were preserved');
335
+ }
336
+ this.runtimeResults.set(target, {
337
+ installed: runtime.installed,
338
+ skipped: runtime.skipped,
339
+ warnings: [],
159
340
  });
341
+ this.markTargetCommitted(target);
342
+ return conflicts;
160
343
  }
161
344
  async recordShared(filePath, component, mutable) {
162
345
  this.validateDestination(filePath);
163
346
  if (!await pathExists(filePath))
164
347
  return;
165
348
  const relative = this.relative(filePath);
166
- const digest = sha256(await fs.promises.readFile(filePath));
167
- await withManifestLock(this.projectRoot, async () => {
168
- const manifest = await this.readManifest();
169
- manifest.shared[relative] = { sha256: digest, component, mutable };
170
- await atomicWriteFile(this.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
349
+ await this.withInstallLock(async () => {
350
+ const assertHeld = this.requireActiveLease();
351
+ const snapshot = await this.readManifestSnapshot();
352
+ const content = await readOptionalBytes(filePath);
353
+ if (content === null)
354
+ return;
355
+ const record = { sha256: sha256(content), component, mutable };
356
+ snapshot.manifest.shared[relative] = record;
357
+ const next = Buffer.from(`${JSON.stringify(snapshot.manifest, null, 2)}\n`);
358
+ await this.verifyOwnedFile(relative, record.sha256);
359
+ await this.replaceBytesCas(this.manifestPath, snapshot.bytes, next, assertHeld);
171
360
  });
172
361
  }
173
362
  async writeIfAbsent(filePath, content) {
@@ -232,78 +421,260 @@ export class PreservingWriter {
232
421
  await fs.promises.mkdir(path.dirname(destination), { recursive: true });
233
422
  await fs.promises.copyFile(source, destination);
234
423
  }
424
+ async verifyPendingOwnership(target, generated, assertHeld) {
425
+ const preservedModified = this.preservedModifiedFiles.get(target);
426
+ for (const [relative, record] of generated) {
427
+ if (preservedModified?.has(relative))
428
+ continue;
429
+ await this.verifyOwnedFile(relative, record.sha256);
430
+ }
431
+ for (const [relative, record] of this.pendingShared) {
432
+ await this.verifyOwnedFile(relative, record.sha256);
433
+ }
434
+ await assertHeld();
435
+ }
436
+ async verifyOwnedFile(relative, expectedSha256) {
437
+ const destination = path.resolve(this.projectRoot, relative);
438
+ this.validateDestination(destination);
439
+ const current = await readOptionalBytes(destination);
440
+ if (current === null || sha256(current) !== expectedSha256) {
441
+ throw new Error(`Managed file changed before manifest commit: ${relative}`);
442
+ }
443
+ }
235
444
  get manifestPath() {
236
445
  return path.join(this.projectRoot, '.sdd-mcp', 'install-manifest.json');
237
446
  }
238
447
  async readManifest() {
239
- try {
240
- const parsed = JSON.parse(await fs.promises.readFile(this.manifestPath, 'utf8'));
241
- if (parsed.schemaVersion !== MANIFEST_SCHEMA_VERSION || !parsed.targets || !parsed.shared) {
242
- throw new Error('Unsupported install manifest schema');
448
+ return (await this.readManifestSnapshot()).manifest;
449
+ }
450
+ async readManifestSnapshot() {
451
+ this.validateDestination(this.manifestPath);
452
+ const bytes = await readOptionalBytes(this.manifestPath);
453
+ return {
454
+ bytes,
455
+ manifest: bytes === null
456
+ ? { schemaVersion: INSTALL_MANIFEST_SCHEMA_VERSION, targets: {}, shared: {} }
457
+ : this.parseManifest(bytes),
458
+ };
459
+ }
460
+ parseManifest(bytes) {
461
+ const value = JSON.parse(bytes.toString('utf8'));
462
+ const parsed = this.requireManifestRecord(value, 'Unsupported install manifest schema');
463
+ if (!hasOnlyKeys(parsed, ['schemaVersion', 'targets', 'shared']) || (parsed.schemaVersion !== 1 && parsed.schemaVersion !== INSTALL_MANIFEST_SCHEMA_VERSION))
464
+ throw new Error('Unsupported install manifest schema');
465
+ const targetsRaw = this.requireManifestRecord(parsed.targets, 'Unsupported install manifest schema');
466
+ const sharedRaw = this.requireManifestRecord(parsed.shared, 'Unsupported install manifest schema');
467
+ const targets = Object.create(null);
468
+ for (const [targetName, targetValue] of Object.entries(targetsRaw)) {
469
+ if (!isInstallTarget(targetName))
470
+ throw new Error('Invalid install manifest target');
471
+ const target = this.requireManifestRecord(targetValue, 'Invalid install manifest ownership entry');
472
+ if (!hasOnlyKeys(target, ['profile', 'packageVersion', 'rendererVersion', 'files', 'registrations'])) {
473
+ throw new Error('Invalid install manifest target metadata');
474
+ }
475
+ if ((target.profile !== 'lean' && target.profile !== 'full')
476
+ || typeof target.packageVersion !== 'string'
477
+ || target.packageVersion.length === 0
478
+ || !Number.isInteger(target.rendererVersion)
479
+ || target.rendererVersion < 1) {
480
+ throw new Error('Invalid install manifest target metadata');
243
481
  }
244
- return parsed;
482
+ const filesRaw = this.requireManifestRecord(target.files, 'Invalid install manifest ownership entry');
483
+ const files = Object.create(null);
484
+ for (const [relativePath, fileValue] of Object.entries(filesRaw)) {
485
+ this.validateManifestRelativePath(relativePath);
486
+ const record = this.requireManifestRecord(fileValue, 'Invalid install manifest ownership entry');
487
+ if (!hasOnlyKeys(record, ['sha256', 'component']) || !isSha256(record.sha256) || !isManifestComponent(record.component)) {
488
+ throw new Error('Invalid install manifest ownership entry');
489
+ }
490
+ files[relativePath] = { sha256: record.sha256, component: record.component };
491
+ }
492
+ if (parsed.schemaVersion === 1 && target.registrations !== undefined) {
493
+ throw new Error('Invalid install manifest registration entry');
494
+ }
495
+ let registrations = [];
496
+ if (parsed.schemaVersion !== 1) {
497
+ if (!Array.isArray(target.registrations) || target.registrations.length > 1) {
498
+ throw new Error('Invalid install manifest registration entry');
499
+ }
500
+ registrations = target.registrations.map((registrationValue) => {
501
+ const registration = this.requireManifestRecord(registrationValue, 'Invalid install manifest registration entry');
502
+ if (!hasOnlyKeys(registration, [
503
+ 'path',
504
+ 'serverName',
505
+ 'entrySemanticSha256',
506
+ 'managedRegionSha256',
507
+ 'permissionPath',
508
+ 'permissionSemanticSha256',
509
+ ])) {
510
+ throw new Error('Invalid install manifest registration entry');
511
+ }
512
+ if (registration.serverName !== 'sdd-mcp'
513
+ || !isSha256(registration.entrySemanticSha256)
514
+ || (registration.managedRegionSha256 !== undefined
515
+ && !isSha256(registration.managedRegionSha256))
516
+ || (registration.permissionSemanticSha256 !== undefined
517
+ && !isSha256(registration.permissionSemanticSha256))) {
518
+ throw new Error('Invalid install manifest registration entry');
519
+ }
520
+ const registrationPath = this.requireManifestRelativePath(registration.path);
521
+ const permissionPath = registration.permissionPath === undefined
522
+ ? undefined
523
+ : this.requireManifestRelativePath(registration.permissionPath);
524
+ if (registration.permissionSemanticSha256 !== undefined && permissionPath === undefined) {
525
+ throw new Error('Invalid install manifest registration entry');
526
+ }
527
+ return {
528
+ path: registrationPath,
529
+ serverName: 'sdd-mcp',
530
+ entrySemanticSha256: registration.entrySemanticSha256,
531
+ managedRegionSha256: typeof registration.managedRegionSha256 === 'string'
532
+ ? registration.managedRegionSha256
533
+ : undefined,
534
+ permissionPath,
535
+ permissionSemanticSha256: typeof registration.permissionSemanticSha256 === 'string'
536
+ ? registration.permissionSemanticSha256
537
+ : undefined,
538
+ };
539
+ });
540
+ }
541
+ targets[targetName] = {
542
+ profile: target.profile,
543
+ packageVersion: target.packageVersion,
544
+ rendererVersion: target.rendererVersion,
545
+ files,
546
+ registrations,
547
+ };
245
548
  }
246
- catch (error) {
247
- if (!isMissing(error))
248
- throw error;
249
- return { schemaVersion: MANIFEST_SCHEMA_VERSION, targets: {}, shared: {} };
549
+ const shared = Object.create(null);
550
+ for (const [relativePath, sharedValue] of Object.entries(sharedRaw)) {
551
+ this.validateManifestRelativePath(relativePath);
552
+ const record = this.requireManifestRecord(sharedValue, 'Invalid install manifest ownership entry');
553
+ if (!hasOnlyKeys(record, ['sha256', 'component', 'mutable'])
554
+ || !isSha256(record.sha256)
555
+ || (record.component !== 'steering' && record.component !== 'gitignore')
556
+ || typeof record.mutable !== 'boolean') {
557
+ throw new Error('Invalid install manifest ownership entry');
558
+ }
559
+ shared[relativePath] = {
560
+ sha256: record.sha256,
561
+ component: record.component,
562
+ mutable: record.mutable,
563
+ };
250
564
  }
565
+ return {
566
+ schemaVersion: INSTALL_MANIFEST_SCHEMA_VERSION,
567
+ targets,
568
+ shared,
569
+ };
570
+ }
571
+ requireManifestRecord(value, message) {
572
+ if (!value || typeof value !== 'object' || Array.isArray(value))
573
+ throw new Error(message);
574
+ return value;
575
+ }
576
+ requireManifestRelativePath(value) {
577
+ if (typeof value !== 'string')
578
+ throw new Error('Invalid install manifest path');
579
+ this.validateManifestRelativePath(value);
580
+ return value;
581
+ }
582
+ validateManifestRelativePath(value) {
583
+ if (!value || value.includes('\\') || value.includes('\0')) {
584
+ throw new Error('Invalid install manifest path');
585
+ }
586
+ const destination = path.resolve(this.projectRoot, value);
587
+ this.validateDestination(destination);
588
+ if (this.relative(destination) !== value)
589
+ throw new Error('Invalid install manifest path');
590
+ }
591
+ async replaceManagedBytesCas(filePath, expected, desired) {
592
+ await this.replaceBytesCas(filePath, expected, desired, this.activeLeaseAssert ?? (async () => undefined));
593
+ }
594
+ async replaceBytesCas(filePath, expected, desired, assertHeld) {
595
+ await assertHeld();
596
+ const current = await readOptionalBytes(filePath);
597
+ if (!buffersEqual(current, expected)) {
598
+ if (buffersEqual(current, desired)) {
599
+ await assertHeld();
600
+ return;
601
+ }
602
+ throw new Error(`Concurrent installer state change at ${this.relative(filePath)}`);
603
+ }
604
+ await atomicWriteFile(filePath, desired.toString('utf8'));
605
+ if (!buffersEqual(await readOptionalBytes(filePath), desired)) {
606
+ throw new Error(`Installer write verification failed at ${this.relative(filePath)}`);
607
+ }
608
+ await assertHeld();
609
+ }
610
+ async removeBytesCas(filePath, expected, assertHeld) {
611
+ await assertHeld();
612
+ if (!buffersEqual(await readOptionalBytes(filePath), expected)) {
613
+ throw new Error(`Concurrent installer state change at ${this.relative(filePath)}`);
614
+ }
615
+ await fs.promises.rm(filePath, { force: true });
616
+ if (await readOptionalBytes(filePath) !== null) {
617
+ throw new Error(`Installer delete verification failed at ${this.relative(filePath)}`);
618
+ }
619
+ await assertHeld();
251
620
  }
252
621
  relative(filePath) {
253
622
  return path.relative(this.projectRoot, filePath).split(path.sep).join('/');
254
623
  }
624
+ trackPendingWrite(target, filePath, prior, next) {
625
+ let writes = this.pendingWrites.get(target);
626
+ if (!writes) {
627
+ writes = new Map();
628
+ this.pendingWrites.set(target, writes);
629
+ }
630
+ if (!writes.has(filePath))
631
+ writes.set(filePath, { prior, next });
632
+ else
633
+ writes.get(filePath).next = next;
634
+ }
635
+ markTargetCommitted(target) {
636
+ this.pendingWrites.delete(target);
637
+ this.committedTargets.add(target);
638
+ }
639
+ requireActiveLease() {
640
+ if (!this.activeLeaseAssert)
641
+ throw new Error('Installer lock lease is not active');
642
+ return this.activeLeaseAssert;
643
+ }
255
644
  validateDestination(destination) {
256
645
  validateDestinationPath(this.projectRoot, destination);
257
646
  }
258
647
  }
259
- const MANIFEST_LOCKS = new Map();
260
- async function withManifestLock(root, action) {
261
- const key = path.resolve(root);
262
- const previous = MANIFEST_LOCKS.get(key) ?? Promise.resolve();
263
- const run = previous.catch(() => undefined).then(async () => {
264
- const releaseFilesystemLock = await acquireFilesystemLock(root);
265
- try {
266
- return await action();
267
- }
268
- finally {
269
- await releaseFilesystemLock();
270
- }
271
- });
272
- const settled = run.then(() => undefined, () => undefined);
273
- MANIFEST_LOCKS.set(key, settled);
648
+ async function readOptionalBytes(filePath) {
274
649
  try {
275
- return await run;
650
+ return await fs.promises.readFile(filePath);
276
651
  }
277
- finally {
278
- if (MANIFEST_LOCKS.get(key) === settled)
279
- MANIFEST_LOCKS.delete(key);
652
+ catch (error) {
653
+ if (isMissing(error))
654
+ return null;
655
+ throw error;
280
656
  }
281
657
  }
282
- async function acquireFilesystemLock(root) {
283
- const lockPath = path.join(root, '.sdd-mcp', 'install-manifest.lock');
284
- validateDestinationPath(root, lockPath);
285
- await fs.promises.mkdir(path.dirname(lockPath), { recursive: true });
286
- for (let attempt = 0; attempt < 100; attempt += 1) {
287
- try {
288
- const handle = await fs.promises.open(lockPath, 'wx');
289
- await handle.writeFile(`${process.pid}\n`);
290
- await handle.close();
291
- return async () => {
292
- await fs.promises.rm(lockPath, { force: true });
293
- };
294
- }
295
- catch (error) {
296
- if (!isAlreadyExists(error))
297
- throw error;
298
- const stats = await fs.promises.stat(lockPath).catch(() => undefined);
299
- if (stats && Date.now() - stats.mtimeMs > 30_000) {
300
- await fs.promises.rm(lockPath, { force: true });
301
- continue;
302
- }
303
- await delay(20);
304
- }
305
- }
306
- throw new Error(`Timed out acquiring install manifest lock: ${lockPath}`);
658
+ function buffersEqual(left, right) {
659
+ return left === null || right === null ? left === right : left.equals(right);
660
+ }
661
+ function hasOnlyKeys(record, allowed) {
662
+ return Object.keys(record).every((key) => allowed.includes(key));
663
+ }
664
+ function isInstallTarget(value) {
665
+ return value === 'claude-code' || value === 'codex' || value === 'omp';
666
+ }
667
+ function isManifestComponent(value) {
668
+ return value === 'root'
669
+ || value === 'skills'
670
+ || value === 'steering'
671
+ || value === 'rules'
672
+ || value === 'contexts'
673
+ || value === 'agents'
674
+ || value === 'hooks';
675
+ }
676
+ function isSha256(value) {
677
+ return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
307
678
  }
308
679
  function sha256(content) {
309
680
  return crypto.createHash('sha256').update(content).digest('hex');