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.
- package/README.md +18 -35
- package/dist/adapters/cli/SDDToolAdapter.d.ts +2 -5
- package/dist/adapters/cli/SDDToolAdapter.js +48 -92
- package/dist/adapters/cli/SDDToolAdapter.js.map +1 -1
- package/dist/application/services/ContextCompactionService.d.ts +10 -3
- package/dist/application/services/ContextCompactionService.js +139 -35
- package/dist/application/services/ContextCompactionService.js.map +1 -1
- package/dist/application/services/ProjectService.js +3 -3
- package/dist/application/services/ProjectService.js.map +1 -1
- package/dist/application/services/SpecPathResolver.js +1 -1
- package/dist/application/services/SpecPathResolver.js.map +1 -1
- package/dist/application/services/WorkflowEngineService.d.ts +160 -50
- package/dist/application/services/WorkflowEngineService.js +1404 -429
- package/dist/application/services/WorkflowEngineService.js.map +1 -1
- package/dist/application/services/WorkflowErrors.d.ts +16 -0
- package/dist/application/services/WorkflowErrors.js +53 -0
- package/dist/application/services/WorkflowErrors.js.map +1 -0
- package/dist/application/services/WorkflowValidationService.d.ts +25 -46
- package/dist/application/services/WorkflowValidationService.js +284 -627
- package/dist/application/services/WorkflowValidationService.js.map +1 -1
- package/dist/cli/install-skills.js.map +1 -1
- package/dist/cli/install-target.d.ts +4 -1
- package/dist/cli/install-target.js +4 -0
- package/dist/cli/install-target.js.map +1 -1
- package/dist/cli/tool-support/claude-code.js +7 -3
- package/dist/cli/tool-support/claude-code.js.map +1 -1
- package/dist/cli/tool-support/codex.js +6 -2
- package/dist/cli/tool-support/codex.js.map +1 -1
- package/dist/cli/tool-support/mcp-registration.d.ts +22 -0
- package/dist/cli/tool-support/mcp-registration.js +275 -0
- package/dist/cli/tool-support/mcp-registration.js.map +1 -0
- package/dist/cli/tool-support/omp.js +6 -2
- package/dist/cli/tool-support/omp.js.map +1 -1
- package/dist/cli/tool-support/root-guidance.js +2 -2
- package/dist/cli/tool-support/root-guidance.js.map +1 -1
- package/dist/cli/tool-support/target-installer.d.ts +2 -2
- package/dist/cli/tool-support/target-installer.js +9 -3
- package/dist/cli/tool-support/target-installer.js.map +1 -1
- package/dist/cli/utils/preserving-writer.d.ts +35 -1
- package/dist/cli/utils/preserving-writer.js +479 -108
- package/dist/cli/utils/preserving-writer.js.map +1 -1
- package/dist/domain/types.d.ts +52 -7
- package/dist/domain/types.js +5 -4
- package/dist/domain/types.js.map +1 -1
- package/dist/infrastructure/mcp/MCPServer.js +13 -13
- package/dist/infrastructure/mcp/MCPServer.js.map +1 -1
- package/dist/infrastructure/mcp/ToolRegistry.d.ts +5 -1
- package/dist/infrastructure/mcp/ToolRegistry.js +11 -4
- package/dist/infrastructure/mcp/ToolRegistry.js.map +1 -1
- package/dist/infrastructure/mcp/sddToolDefinitions.js +76 -90
- package/dist/infrastructure/mcp/sddToolDefinitions.js.map +1 -1
- package/dist/infrastructure/schemas/project.schema.d.ts +2 -2
- package/dist/infrastructure/schemas/project.schema.js +2 -2
- package/dist/infrastructure/schemas/project.schema.js.map +1 -1
- package/dist/shared/version.d.ts +3 -0
- package/dist/shared/version.js +4 -0
- package/dist/shared/version.js.map +1 -0
- package/dist/utils/atomicWrite.js +20 -5
- package/dist/utils/atomicWrite.js.map +1 -1
- package/dist/utils/withFilesystemLock.d.ts +22 -0
- package/dist/utils/withFilesystemLock.js +219 -0
- package/dist/utils/withFilesystemLock.js.map +1 -0
- package/package.json +4 -2
- package/skills/sdd-design/REFERENCE.md +16 -0
- package/skills/sdd-design/SKILL.md +22 -13
- package/skills/sdd-implement/REFERENCE.md +4 -0
- package/skills/sdd-implement/SKILL.md +20 -16
- package/skills/sdd-requirements/REFERENCE.md +15 -7
- package/skills/sdd-requirements/SKILL.md +30 -22
- package/skills/sdd-tasks/REFERENCE.md +9 -9
- package/skills/sdd-tasks/SKILL.md +27 -15
- package/templates/CLAUDE.md +6 -12
- 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 {
|
|
4
|
+
import { INSTALL_MANIFEST_SCHEMA_VERSION, INSTALL_RENDERER_VERSION, PACKAGE_VERSION } from '../../shared/version.js';
|
|
5
5
|
import { atomicWriteFile } from '../../utils/atomicWrite.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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)
|
|
157
|
+
const generated = this.generatedFor(target);
|
|
81
158
|
const prior = manifest.targets[target]?.files[relative];
|
|
82
159
|
if (!exists) {
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
296
|
+
await this.removeBytesCas(destination, currentBytes, assertHeld);
|
|
136
297
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
|
650
|
+
return await fs.promises.readFile(filePath);
|
|
276
651
|
}
|
|
277
|
-
|
|
278
|
-
if (
|
|
279
|
-
|
|
652
|
+
catch (error) {
|
|
653
|
+
if (isMissing(error))
|
|
654
|
+
return null;
|
|
655
|
+
throw error;
|
|
280
656
|
}
|
|
281
657
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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');
|