engineering-memory 0.1.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/bin/engineering-memory.mjs +120 -0
- package/dispatcher/managed-section.mjs +59 -0
- package/dispatcher/sections.mjs +14 -0
- package/install/api-url.mjs +39 -0
- package/install/cli.mjs +93 -0
- package/install/commands.mjs +140 -0
- package/install/files.mjs +416 -0
- package/install/git-hook.mjs +270 -0
- package/install/installer.mjs +279 -0
- package/install/mcp-registration.mjs +457 -0
- package/package.json +28 -0
- package/runtime/dist/src/auth/browser-auth.js +184 -0
- package/runtime/dist/src/auth/credential-store.js +181 -0
- package/runtime/dist/src/cache/etag-cache.js +123 -0
- package/runtime/dist/src/config.js +59 -0
- package/runtime/dist/src/git/git-inspector.js +375 -0
- package/runtime/dist/src/git/pre-commit.js +44 -0
- package/runtime/dist/src/git/verification-gate.js +221 -0
- package/runtime/dist/src/index.js +60 -0
- package/runtime/dist/src/journal/journal-store.js +1300 -0
- package/runtime/dist/src/mcp/server.js +11 -0
- package/runtime/dist/src/mcp/tool-definitions.js +405 -0
- package/runtime/dist/src/project/repository.js +79 -0
- package/runtime/dist/src/runtime/active-context-store.js +356 -0
- package/runtime/dist/src/runtime/api-client.js +229 -0
- package/runtime/dist/src/runtime/bridge-service.js +2226 -0
- package/runtime/dist/src/runtime/offline-outbox.js +274 -0
- package/runtime/dist/src/runtime/principal-state.js +97 -0
- package/runtime/dist/src/types.js +2 -0
- package/runtime/dist/src/utilities/files.js +189 -0
- package/runtime/dist/src/utilities/hash.js +19 -0
- package/runtime/dist/src/utilities/process.js +32 -0
- package/runtime/package-lock.json +137 -0
- package/runtime/package.json +32 -0
- package/skill/SKILL.md +29 -0
- package/skill/agents/openai.yaml +6 -0
- package/skill/references/lifecycle.md +102 -0
- package/skill/references/memory-updates.md +25 -0
- package/skill/references/questionnaires.md +98 -0
- package/skill/references/scaffolding.md +38 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
cp,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdtemp,
|
|
7
|
+
mkdir,
|
|
8
|
+
readFile,
|
|
9
|
+
readdir,
|
|
10
|
+
rename,
|
|
11
|
+
rm,
|
|
12
|
+
writeFile,
|
|
13
|
+
} from 'node:fs/promises';
|
|
14
|
+
import { basename, dirname, join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
export class InstallTransaction {
|
|
17
|
+
constructor() {
|
|
18
|
+
this.rollbacks = [];
|
|
19
|
+
this.cleanups = [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
add(rollback, cleanup = async () => {}) {
|
|
23
|
+
this.rollbacks.push(rollback);
|
|
24
|
+
this.cleanups.push(cleanup);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async commit() {
|
|
28
|
+
const errors = [];
|
|
29
|
+
for (const cleanup of this.cleanups) {
|
|
30
|
+
try {
|
|
31
|
+
await cleanup();
|
|
32
|
+
} catch (error) {
|
|
33
|
+
errors.push(error);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
this.rollbacks = [];
|
|
37
|
+
this.cleanups = [];
|
|
38
|
+
if (errors.length > 0) {
|
|
39
|
+
throw new AggregateError(
|
|
40
|
+
errors,
|
|
41
|
+
'Installation succeeded but backup cleanup failed',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async rollback() {
|
|
47
|
+
const errors = [];
|
|
48
|
+
for (const rollback of [...this.rollbacks].reverse()) {
|
|
49
|
+
try {
|
|
50
|
+
await rollback();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
errors.push(error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
this.rollbacks = [];
|
|
56
|
+
this.cleanups = [];
|
|
57
|
+
if (errors.length > 0) {
|
|
58
|
+
throw new AggregateError(errors, 'Installer rollback failed');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function exists(path) {
|
|
64
|
+
try {
|
|
65
|
+
await lstat(path);
|
|
66
|
+
return true;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error?.code === 'ENOENT') return false;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function readText(path) {
|
|
74
|
+
if (!(await exists(path))) return '';
|
|
75
|
+
return readFile(path, 'utf8');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function readJson(path) {
|
|
79
|
+
if (!(await exists(path))) return null;
|
|
80
|
+
let value;
|
|
81
|
+
try {
|
|
82
|
+
value = JSON.parse(await readFile(path, 'utf8'));
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error(`Managed state is not valid JSON: ${path}`);
|
|
85
|
+
}
|
|
86
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
87
|
+
throw new Error(`Managed state must be an object: ${path}`);
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function replaceFile(path, content, transaction, mode) {
|
|
93
|
+
const previous = (await exists(path)) ? await readFile(path) : null;
|
|
94
|
+
await writeAtomic(path, content, mode);
|
|
95
|
+
transaction.add(async () => {
|
|
96
|
+
if (previous === null) await rm(path, { force: true });
|
|
97
|
+
else await writeAtomic(path, previous, mode);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function writeJson(path, value, transaction) {
|
|
102
|
+
await replaceFile(path, `${JSON.stringify(value, null, 2)}\n`, transaction);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function installManagedDirectory(
|
|
106
|
+
source,
|
|
107
|
+
destination,
|
|
108
|
+
transaction,
|
|
109
|
+
options = {},
|
|
110
|
+
) {
|
|
111
|
+
const artifact = options.artifact ?? 'skill';
|
|
112
|
+
const artifactLabel = artifact === 'skill' ? 'skill' : 'bridge runtime';
|
|
113
|
+
const sourceHash = await hashDirectory(source);
|
|
114
|
+
const manifestPath = join(destination, '.engineering-memory-install.json');
|
|
115
|
+
if (await exists(destination)) {
|
|
116
|
+
const manifest = await readJson(manifestPath);
|
|
117
|
+
if (
|
|
118
|
+
manifest?.product !== 'engineering-memory' ||
|
|
119
|
+
manifest?.schemaVersion !== 1 ||
|
|
120
|
+
(manifest.artifact ?? 'skill') !== artifact
|
|
121
|
+
) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`Refusing to overwrite an unmanaged ${artifactLabel} directory: ${destination}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const destinationHash = await hashDirectory(destination, {
|
|
127
|
+
exclude: ['.engineering-memory-install.json'],
|
|
128
|
+
});
|
|
129
|
+
if (destinationHash !== manifest.sourceHash) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Refusing to overwrite a locally modified managed ${artifactLabel}: ${destination}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (manifest.sourceHash === sourceHash) return sourceHash;
|
|
135
|
+
}
|
|
136
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
137
|
+
const stage = join(
|
|
138
|
+
dirname(destination),
|
|
139
|
+
`.${basename(destination)}.stage-${randomUUID()}`,
|
|
140
|
+
);
|
|
141
|
+
const backup = join(
|
|
142
|
+
dirname(destination),
|
|
143
|
+
`.${basename(destination)}.backup-${randomUUID()}`,
|
|
144
|
+
);
|
|
145
|
+
await copyDirectory(source, stage);
|
|
146
|
+
await writeAtomic(
|
|
147
|
+
join(stage, '.engineering-memory-install.json'),
|
|
148
|
+
`${JSON.stringify(
|
|
149
|
+
{
|
|
150
|
+
product: 'engineering-memory',
|
|
151
|
+
schemaVersion: 1,
|
|
152
|
+
artifact,
|
|
153
|
+
sourceHash,
|
|
154
|
+
},
|
|
155
|
+
null,
|
|
156
|
+
2,
|
|
157
|
+
)}\n`,
|
|
158
|
+
);
|
|
159
|
+
const hadDestination = await exists(destination);
|
|
160
|
+
try {
|
|
161
|
+
if (hadDestination) await rename(destination, backup);
|
|
162
|
+
await rename(stage, destination);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
await rm(stage, { recursive: true, force: true });
|
|
165
|
+
if (hadDestination && (await exists(backup))) {
|
|
166
|
+
await rename(backup, destination);
|
|
167
|
+
}
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
transaction.add(
|
|
171
|
+
async () => {
|
|
172
|
+
await rm(destination, { recursive: true, force: true });
|
|
173
|
+
if (hadDestination) await rename(backup, destination);
|
|
174
|
+
},
|
|
175
|
+
async () => {
|
|
176
|
+
await rm(backup, { recursive: true, force: true });
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
return sourceHash;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function installManagedBridgeRuntime(
|
|
183
|
+
source,
|
|
184
|
+
destination,
|
|
185
|
+
transaction,
|
|
186
|
+
) {
|
|
187
|
+
const packageJsonPath = join(source, 'package.json');
|
|
188
|
+
const packageLockPath = join(source, 'package-lock.json');
|
|
189
|
+
const nodeModulesPath = join(source, 'node_modules');
|
|
190
|
+
const packageJson = await readRequiredJson(
|
|
191
|
+
packageJsonPath,
|
|
192
|
+
'Bridge package metadata',
|
|
193
|
+
);
|
|
194
|
+
const packageLock = await readRequiredJson(
|
|
195
|
+
packageLockPath,
|
|
196
|
+
'Bridge dependency lockfile',
|
|
197
|
+
);
|
|
198
|
+
if (!(await exists(join(source, 'dist')))) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Built bridge runtime is missing ${join(source, 'dist')}. Run npm run build in the bridge directory before installing.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (!(await exists(nodeModulesPath))) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Bridge runtime dependencies are missing: ${nodeModulesPath}. Run npm ci in the bridge directory before installing.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const productionPackages = resolveProductionPackagePaths(
|
|
209
|
+
packageJson,
|
|
210
|
+
packageLock,
|
|
211
|
+
);
|
|
212
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
213
|
+
const bundle = await mkdtemp(
|
|
214
|
+
join(dirname(destination), '.bridge-runtime-source-'),
|
|
215
|
+
);
|
|
216
|
+
try {
|
|
217
|
+
await copyDirectory(join(source, 'dist'), join(bundle, 'dist'));
|
|
218
|
+
await copyFile(packageJsonPath, join(bundle, 'package.json'));
|
|
219
|
+
await copyFile(packageLockPath, join(bundle, 'package-lock.json'));
|
|
220
|
+
for (const packageEntry of productionPackages) {
|
|
221
|
+
const sourcePath = join(source, ...packageEntry.path.split('/'));
|
|
222
|
+
if (!(await exists(sourcePath))) {
|
|
223
|
+
if (packageEntry.optional) continue;
|
|
224
|
+
throw new Error(
|
|
225
|
+
`Bridge production dependency is missing: ${sourcePath}. Run npm ci in the bridge directory before installing.`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
await copyPackageDirectory(
|
|
229
|
+
sourcePath,
|
|
230
|
+
join(bundle, ...packageEntry.path.split('/')),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return await installManagedDirectory(bundle, destination, transaction, {
|
|
234
|
+
artifact: 'bridge-runtime',
|
|
235
|
+
});
|
|
236
|
+
} finally {
|
|
237
|
+
await rm(bundle, { recursive: true, force: true });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function hashDirectory(root, options = {}) {
|
|
242
|
+
const hash = createHash('sha256');
|
|
243
|
+
const excluded = new Set(options.exclude ?? []);
|
|
244
|
+
const entries = (await listFiles(root)).filter(
|
|
245
|
+
(entry) => !excluded.has(entry.replaceAll('\\', '/')),
|
|
246
|
+
);
|
|
247
|
+
for (const relativePath of entries) {
|
|
248
|
+
hash.update(relativePath.replaceAll('\\', '/'));
|
|
249
|
+
hash.update('\0');
|
|
250
|
+
hash.update(await readFile(join(root, relativePath)));
|
|
251
|
+
hash.update('\0');
|
|
252
|
+
}
|
|
253
|
+
return hash.digest('hex');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function copyDirectory(source, destination) {
|
|
257
|
+
await assertSafeTree(source);
|
|
258
|
+
await cp(source, destination, {
|
|
259
|
+
recursive: true,
|
|
260
|
+
errorOnExist: true,
|
|
261
|
+
force: false,
|
|
262
|
+
verbatimSymlinks: true,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function copyPackageDirectory(source, destination) {
|
|
267
|
+
const status = await lstat(source);
|
|
268
|
+
if (!status.isDirectory()) {
|
|
269
|
+
throw new Error(`Bridge dependency is not a directory: ${source}`);
|
|
270
|
+
}
|
|
271
|
+
await mkdir(destination, { recursive: true });
|
|
272
|
+
const entries = await readdir(source, { withFileTypes: true });
|
|
273
|
+
for (const entry of entries) {
|
|
274
|
+
const sourcePath = join(source, entry.name);
|
|
275
|
+
const destinationPath = join(destination, entry.name);
|
|
276
|
+
if (entry.isSymbolicLink()) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`Bridge dependency cannot contain symbolic links: ${sourcePath}`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (entry.isDirectory()) {
|
|
282
|
+
if (entry.name !== 'node_modules') {
|
|
283
|
+
await copyPackageDirectory(sourcePath, destinationPath);
|
|
284
|
+
}
|
|
285
|
+
} else if (entry.isFile()) {
|
|
286
|
+
await copyFile(sourcePath, destinationPath);
|
|
287
|
+
} else {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`Bridge dependency contains an unsupported entry: ${sourcePath}`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function copyFile(source, destination) {
|
|
296
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
297
|
+
await cp(source, destination, {
|
|
298
|
+
errorOnExist: true,
|
|
299
|
+
force: false,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function readRequiredJson(path, label) {
|
|
304
|
+
if (!(await exists(path))) {
|
|
305
|
+
throw new Error(`${label} is missing: ${path}`);
|
|
306
|
+
}
|
|
307
|
+
let value;
|
|
308
|
+
try {
|
|
309
|
+
value = JSON.parse(await readFile(path, 'utf8'));
|
|
310
|
+
} catch {
|
|
311
|
+
throw new Error(`${label} is not valid JSON: ${path}`);
|
|
312
|
+
}
|
|
313
|
+
if (!value || Array.isArray(value)) {
|
|
314
|
+
throw new Error(`${label} must be a JSON object: ${path}`);
|
|
315
|
+
}
|
|
316
|
+
return value;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function resolveProductionPackagePaths(packageJson, packageLock) {
|
|
320
|
+
if (
|
|
321
|
+
packageLock.lockfileVersion !== 3 ||
|
|
322
|
+
!packageLock.packages ||
|
|
323
|
+
typeof packageLock.packages !== 'object' ||
|
|
324
|
+
Array.isArray(packageLock.packages)
|
|
325
|
+
) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
'Bridge package-lock.json must use lockfileVersion 3 and contain package records',
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const rootDependencies = Object.keys(packageJson.dependencies ?? {});
|
|
331
|
+
for (const dependency of rootDependencies) {
|
|
332
|
+
if (!packageLock.packages[`node_modules/${dependency}`]) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`Bridge lockfile does not contain production dependency ${dependency}. Run npm ci in the bridge directory before installing.`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return Object.entries(packageLock.packages)
|
|
339
|
+
.filter(
|
|
340
|
+
([path, metadata]) =>
|
|
341
|
+
path.startsWith('node_modules/') &&
|
|
342
|
+
metadata &&
|
|
343
|
+
typeof metadata === 'object' &&
|
|
344
|
+
metadata.dev !== true &&
|
|
345
|
+
metadata.link !== true,
|
|
346
|
+
)
|
|
347
|
+
.map(([path, metadata]) => {
|
|
348
|
+
if (
|
|
349
|
+
path.includes('\\') ||
|
|
350
|
+
path.split('/').some((segment) => segment === '..' || segment === '')
|
|
351
|
+
) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
`Bridge lockfile contains an unsafe package path: ${path}`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
return { path, optional: metadata.optional === true };
|
|
357
|
+
})
|
|
358
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function assertSafeTree(root) {
|
|
362
|
+
const status = await lstat(root);
|
|
363
|
+
if (!status.isDirectory())
|
|
364
|
+
throw new Error(`Skill source is not a directory: ${root}`);
|
|
365
|
+
const queue = [root];
|
|
366
|
+
while (queue.length > 0) {
|
|
367
|
+
const current = queue.pop();
|
|
368
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
369
|
+
for (const entry of entries) {
|
|
370
|
+
const path = join(current, entry.name);
|
|
371
|
+
if (entry.isSymbolicLink()) {
|
|
372
|
+
throw new Error(`Skill source cannot contain symbolic links: ${path}`);
|
|
373
|
+
}
|
|
374
|
+
if (entry.isDirectory()) queue.push(path);
|
|
375
|
+
else if (!entry.isFile()) {
|
|
376
|
+
throw new Error(`Skill source contains an unsupported entry: ${path}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function listFiles(root) {
|
|
383
|
+
await assertSafeTree(root);
|
|
384
|
+
const result = [];
|
|
385
|
+
const visit = async (current, prefix) => {
|
|
386
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
387
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
388
|
+
for (const entry of entries) {
|
|
389
|
+
const relativePath = prefix ? join(prefix, entry.name) : entry.name;
|
|
390
|
+
if (entry.isDirectory())
|
|
391
|
+
await visit(join(current, entry.name), relativePath);
|
|
392
|
+
else result.push(relativePath);
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
await visit(root, '');
|
|
396
|
+
return result;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function writeAtomic(path, content, mode) {
|
|
400
|
+
await mkdir(dirname(path), { recursive: true });
|
|
401
|
+
const temporary = join(
|
|
402
|
+
dirname(path),
|
|
403
|
+
`.${basename(path)}.${randomUUID()}.tmp`,
|
|
404
|
+
);
|
|
405
|
+
try {
|
|
406
|
+
await writeFile(
|
|
407
|
+
temporary,
|
|
408
|
+
content,
|
|
409
|
+
mode === undefined ? undefined : { mode },
|
|
410
|
+
);
|
|
411
|
+
await rename(temporary, path);
|
|
412
|
+
if (mode !== undefined) await chmod(path, mode);
|
|
413
|
+
} finally {
|
|
414
|
+
await rm(temporary, { force: true });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, readFile, rename } from 'node:fs/promises';
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { commandFailure } from './commands.mjs';
|
|
5
|
+
import { exists, replaceFile } from './files.mjs';
|
|
6
|
+
|
|
7
|
+
const MANAGED_MARKER = 'ENGINEERING_MEMORY_MANAGED=1';
|
|
8
|
+
const modes = new Set(['chain', 'verify-only', 'cancel']);
|
|
9
|
+
|
|
10
|
+
export async function planGitHook({
|
|
11
|
+
repoRoot,
|
|
12
|
+
mode,
|
|
13
|
+
nodePath,
|
|
14
|
+
gateEntry,
|
|
15
|
+
apiUrl,
|
|
16
|
+
commandRunner,
|
|
17
|
+
state,
|
|
18
|
+
}) {
|
|
19
|
+
if (!repoRoot) return { action: 'skip', mode: 'cancel' };
|
|
20
|
+
if (!modes.has(mode)) {
|
|
21
|
+
throw new Error('Hook mode must be chain, verify-only, or cancel');
|
|
22
|
+
}
|
|
23
|
+
if (mode === 'cancel') return { action: 'skip', mode };
|
|
24
|
+
const { hooksPath } = await resolveRepositoryHooks(repoRoot, commandRunner);
|
|
25
|
+
const preCommitPath = join(hooksPath, 'pre-commit');
|
|
26
|
+
const previousPath = join(
|
|
27
|
+
hooksPath,
|
|
28
|
+
'pre-commit.engineering-memory.previous',
|
|
29
|
+
);
|
|
30
|
+
const verifyPath = join(hooksPath, 'engineering-memory-verify');
|
|
31
|
+
if (mode === 'verify-only') {
|
|
32
|
+
if (await exists(verifyPath)) {
|
|
33
|
+
if (
|
|
34
|
+
!(await isManaged(verifyPath)) ||
|
|
35
|
+
!(await matchesState(verifyPath, state))
|
|
36
|
+
) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Refusing to overwrite an unmanaged or modified verify command: ${verifyPath}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
action: 'verify-only',
|
|
44
|
+
mode,
|
|
45
|
+
apiUrl,
|
|
46
|
+
verifyPath,
|
|
47
|
+
content: hookContent({ nodePath, gateEntry, apiUrl }),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const currentExists = await exists(preCommitPath);
|
|
51
|
+
const currentManaged = currentExists && (await isManaged(preCommitPath));
|
|
52
|
+
if (currentManaged && !(await matchesState(preCommitPath, state))) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Refusing to overwrite an unmanaged or modified pre-commit hook: ${preCommitPath}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (currentExists && !currentManaged) {
|
|
58
|
+
if (await exists(previousPath)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Cannot chain the existing pre-commit hook because the preserved hook path already exists: ${previousPath}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
action: 'chain-existing',
|
|
65
|
+
mode,
|
|
66
|
+
apiUrl,
|
|
67
|
+
preCommitPath,
|
|
68
|
+
previousPath,
|
|
69
|
+
content: hookContent({
|
|
70
|
+
nodePath,
|
|
71
|
+
gateEntry,
|
|
72
|
+
apiUrl,
|
|
73
|
+
previousPath,
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
action: 'chain',
|
|
79
|
+
mode,
|
|
80
|
+
apiUrl,
|
|
81
|
+
preCommitPath,
|
|
82
|
+
previousPath: (await exists(previousPath)) ? previousPath : null,
|
|
83
|
+
content: hookContent({
|
|
84
|
+
nodePath,
|
|
85
|
+
gateEntry,
|
|
86
|
+
apiUrl,
|
|
87
|
+
previousPath: (await exists(previousPath)) ? previousPath : null,
|
|
88
|
+
}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function applyGitHook(plan, transaction) {
|
|
93
|
+
if (plan.action === 'skip') return { mode: plan.mode };
|
|
94
|
+
if (plan.action === 'verify-only') {
|
|
95
|
+
await replaceFile(plan.verifyPath, plan.content, transaction, 0o755);
|
|
96
|
+
return {
|
|
97
|
+
mode: plan.mode,
|
|
98
|
+
verifyPath: plan.verifyPath,
|
|
99
|
+
apiUrl: plan.apiUrl,
|
|
100
|
+
contentHash: hash(plan.content),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (plan.action === 'chain-existing') {
|
|
104
|
+
await rename(plan.preCommitPath, plan.previousPath);
|
|
105
|
+
transaction.add(async () => {
|
|
106
|
+
if (await exists(plan.preCommitPath)) return;
|
|
107
|
+
await rename(plan.previousPath, plan.preCommitPath);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
await replaceFile(plan.preCommitPath, plan.content, transaction, 0o755);
|
|
111
|
+
return {
|
|
112
|
+
mode: plan.mode,
|
|
113
|
+
hookPath: plan.preCommitPath,
|
|
114
|
+
chainedHookPath: plan.previousPath ?? undefined,
|
|
115
|
+
apiUrl: plan.apiUrl,
|
|
116
|
+
contentHash: hash(plan.content),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function isManaged(path) {
|
|
121
|
+
return (await readFile(path, 'utf8'))
|
|
122
|
+
.split(/\r?\n/)
|
|
123
|
+
.some((line) => line === MANAGED_MARKER);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function matchesState(path, state) {
|
|
127
|
+
if (!state || state.mode === 'cancel') return false;
|
|
128
|
+
const expectedPath = state.hookPath ?? state.verifyPath;
|
|
129
|
+
if (expectedPath !== path || typeof state.contentHash !== 'string')
|
|
130
|
+
return false;
|
|
131
|
+
return hash(await readFile(path, 'utf8')) === state.contentHash;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function resolveRepositoryHooks(repoRoot, commandRunner) {
|
|
135
|
+
const [gitDirectory, commonDirectory, effectiveHooks, configuredHooks] =
|
|
136
|
+
await Promise.all([
|
|
137
|
+
runGitPathQuery(repoRoot, commandRunner, '--git-dir'),
|
|
138
|
+
runGitPathQuery(repoRoot, commandRunner, '--git-common-dir'),
|
|
139
|
+
runGitPathQuery(repoRoot, commandRunner, '--git-path', 'hooks'),
|
|
140
|
+
commandRunner('git', [
|
|
141
|
+
'-C',
|
|
142
|
+
repoRoot,
|
|
143
|
+
'config',
|
|
144
|
+
'--show-origin',
|
|
145
|
+
'--get',
|
|
146
|
+
'core.hooksPath',
|
|
147
|
+
]),
|
|
148
|
+
]);
|
|
149
|
+
if (configuredHooks.code !== 0 && configuredHooks.code !== 1) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Could not inspect core.hooksPath for ${repoRoot}. ${commandFailure('git', configuredHooks)}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (configuredHooks.code === 0 && configuredHooks.stdout.trim()) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`Refusing to install while core.hooksPath is configured. A global, system, or shared hooks setting could make unrelated repositories execute this gate: ${configuredHooks.stdout.trim()}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const gitDir = resolveGitPath(repoRoot, gitDirectory, '--git-dir');
|
|
160
|
+
const commonDir = resolveGitPath(
|
|
161
|
+
repoRoot,
|
|
162
|
+
commonDirectory,
|
|
163
|
+
'--git-common-dir',
|
|
164
|
+
);
|
|
165
|
+
const hooksPath = resolveGitPath(
|
|
166
|
+
repoRoot,
|
|
167
|
+
effectiveHooks,
|
|
168
|
+
'--git-path hooks',
|
|
169
|
+
);
|
|
170
|
+
assertSupportedGitDirectoryLayout(gitDir, commonDir);
|
|
171
|
+
const repositoryHooksPath = join(commonDir, 'hooks');
|
|
172
|
+
if (!samePath(hooksPath, repositoryHooksPath)) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Refusing to install into a shared, global, or custom Git hooks path: ${hooksPath}. Expected the repository-owned common hooks directory ${repositoryHooksPath}.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (await exists(hooksPath)) {
|
|
178
|
+
const hooksStatus = await lstat(hooksPath);
|
|
179
|
+
if (hooksStatus.isSymbolicLink() || !hooksStatus.isDirectory()) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Refusing to install into a Git hooks path that is not a repository-owned directory: ${hooksPath}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { gitDir, commonDir, hooksPath };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function runGitPathQuery(repoRoot, commandRunner, ...arguments_) {
|
|
189
|
+
return commandRunner('git', [
|
|
190
|
+
'-C',
|
|
191
|
+
repoRoot,
|
|
192
|
+
'rev-parse',
|
|
193
|
+
'--path-format=absolute',
|
|
194
|
+
...arguments_,
|
|
195
|
+
]);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function resolveGitPath(repoRoot, result, query) {
|
|
199
|
+
if (result.code !== 0) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Could not resolve Git ${query} for ${repoRoot}. ${commandFailure('git', result)}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
const rawPath = result.stdout.trim();
|
|
205
|
+
if (!rawPath) throw new Error(`Git returned an empty ${query} path`);
|
|
206
|
+
return isAbsolute(rawPath) ? resolve(rawPath) : resolve(repoRoot, rawPath);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function assertSupportedGitDirectoryLayout(gitDir, commonDir) {
|
|
210
|
+
if (samePath(gitDir, commonDir)) return;
|
|
211
|
+
const worktreesDirectory = join(commonDir, 'worktrees');
|
|
212
|
+
const relativeGitDir = relative(worktreesDirectory, gitDir);
|
|
213
|
+
if (
|
|
214
|
+
relativeGitDir === '' ||
|
|
215
|
+
relativeGitDir === '..' ||
|
|
216
|
+
relativeGitDir.startsWith(`..\\`) ||
|
|
217
|
+
relativeGitDir.startsWith('../') ||
|
|
218
|
+
isAbsolute(relativeGitDir)
|
|
219
|
+
) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Refusing an unsupported Git directory layout: ${gitDir} is not the common directory ${commonDir} or one of its linked worktrees`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function samePath(left, right) {
|
|
227
|
+
const normalize = (value) => {
|
|
228
|
+
const normalized = resolve(value).replaceAll('\\', '/').replace(/\/+$/, '');
|
|
229
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
230
|
+
};
|
|
231
|
+
return normalize(left) === normalize(right);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function hookContent({ nodePath, gateEntry, apiUrl, previousPath }) {
|
|
235
|
+
const lines = ['#!/bin/sh', MANAGED_MARKER];
|
|
236
|
+
if (previousPath) {
|
|
237
|
+
lines.push(
|
|
238
|
+
`if [ -x ${shellQuote(previousPath)} ]; then`,
|
|
239
|
+
` ${shellQuote(previousPath)} "$@"`,
|
|
240
|
+
' status=$?',
|
|
241
|
+
' if [ "$status" -ne 0 ]; then exit "$status"; fi',
|
|
242
|
+
'fi',
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
lines.push(
|
|
246
|
+
`export ENGINEERING_MEMORY_API_URL=${shellQuote(apiUrl)}`,
|
|
247
|
+
'repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {',
|
|
248
|
+
" echo 'Engineering Memory: could not resolve the current repository root.' >&2",
|
|
249
|
+
' exit 1',
|
|
250
|
+
'}',
|
|
251
|
+
'if [ -z "$repo_root" ]; then',
|
|
252
|
+
" echo 'Engineering Memory: Git returned an empty repository root.' >&2",
|
|
253
|
+
' exit 1',
|
|
254
|
+
'fi',
|
|
255
|
+
`exec ${shellQuote(nodePath)} ${shellQuote(gateEntry)} "$repo_root"`,
|
|
256
|
+
'',
|
|
257
|
+
);
|
|
258
|
+
return lines.join('\n');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function shellQuote(value) {
|
|
262
|
+
const normalized = value.replaceAll('\\', '/');
|
|
263
|
+
return `'${normalized.replaceAll("'", `'"'"'`)}'`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function hash(value) {
|
|
267
|
+
return createHash('sha256').update(value).digest('hex');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export const gitHookModes = Object.freeze([...modes]);
|