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,457 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { commandFailure } from './commands.mjs';
|
|
3
|
+
|
|
4
|
+
const SERVER_NAME = 'engineering-memory';
|
|
5
|
+
const API_URL_ENV = 'ENGINEERING_MEMORY_API_URL';
|
|
6
|
+
const missingServerPattern =
|
|
7
|
+
/not found|does not exist|unknown (?:mcp )?server|no (?:mcp )?server|is not configured/i;
|
|
8
|
+
|
|
9
|
+
const clients = Object.freeze({
|
|
10
|
+
codex: {
|
|
11
|
+
executable: 'codex',
|
|
12
|
+
addArgs: (registration) => [
|
|
13
|
+
'mcp',
|
|
14
|
+
'add',
|
|
15
|
+
SERVER_NAME,
|
|
16
|
+
...environmentArgs(registration),
|
|
17
|
+
'--',
|
|
18
|
+
registration.nodePath,
|
|
19
|
+
registration.bridgeEntry,
|
|
20
|
+
],
|
|
21
|
+
getArgs: ['mcp', 'get', SERVER_NAME, '--json'],
|
|
22
|
+
removeArgs: ['mcp', 'remove', SERVER_NAME],
|
|
23
|
+
},
|
|
24
|
+
claude: {
|
|
25
|
+
executable: 'claude',
|
|
26
|
+
addArgs: (registration) => [
|
|
27
|
+
'mcp',
|
|
28
|
+
'add',
|
|
29
|
+
'--scope',
|
|
30
|
+
'user',
|
|
31
|
+
SERVER_NAME,
|
|
32
|
+
...environmentArgs(registration),
|
|
33
|
+
'--',
|
|
34
|
+
registration.nodePath,
|
|
35
|
+
registration.bridgeEntry,
|
|
36
|
+
],
|
|
37
|
+
getArgs: ['mcp', 'get', SERVER_NAME],
|
|
38
|
+
removeArgs: ['mcp', 'remove', '--scope', 'user', SERVER_NAME],
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export async function planMcpRegistrations({
|
|
43
|
+
selectedClients,
|
|
44
|
+
bridgeEntry,
|
|
45
|
+
nodePath,
|
|
46
|
+
apiUrl,
|
|
47
|
+
state,
|
|
48
|
+
commandRunner,
|
|
49
|
+
}) {
|
|
50
|
+
const plans = [];
|
|
51
|
+
for (const clientName of selectedClients) {
|
|
52
|
+
const client = clients[clientName];
|
|
53
|
+
if (!client) throw new Error(`Unsupported client: ${clientName}`);
|
|
54
|
+
const version = await commandRunner(client.executable, ['--version']);
|
|
55
|
+
if (version.code !== 0) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`${clientName} CLI is unavailable. Install its native CLI, ensure it is on PATH, then rerun the installer. ${commandFailure(client.executable, version)}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const existing = await commandRunner(client.executable, client.getArgs);
|
|
61
|
+
const managed = state?.mcp?.[clientName];
|
|
62
|
+
const registration = createRegistration(clientName, {
|
|
63
|
+
nodePath,
|
|
64
|
+
bridgeEntry,
|
|
65
|
+
apiUrl,
|
|
66
|
+
});
|
|
67
|
+
if (existing.code === 0) {
|
|
68
|
+
if (!isOwnedRegistration(clientName, managed)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Refusing to overwrite the existing ${clientName} MCP server named ${SERVER_NAME}. Remove or rename it explicitly, then rerun the installer.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const actual = parseExistingRegistration(clientName, existing.stdout);
|
|
74
|
+
if (!sameCanonicalRegistration(actual, canonicalRegistration(managed))) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Refusing to modify the existing ${clientName} MCP server named ${SERVER_NAME}: its actual command, arguments, or environment differ from installer-managed state. Restore or remove it explicitly, then rerun the installer.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (sameRegistration(managed, registration)) {
|
|
80
|
+
plans.push({
|
|
81
|
+
clientName,
|
|
82
|
+
client,
|
|
83
|
+
action: 'unchanged',
|
|
84
|
+
registration,
|
|
85
|
+
});
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
plans.push({
|
|
89
|
+
clientName,
|
|
90
|
+
client,
|
|
91
|
+
action: 'replace',
|
|
92
|
+
registration,
|
|
93
|
+
previousRegistration: managed,
|
|
94
|
+
});
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const output = `${existing.stderr}\n${existing.stdout}`;
|
|
98
|
+
if (existing.error || !missingServerPattern.test(output)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Could not safely determine whether ${clientName} MCP server ${SERVER_NAME} exists. ${commandFailure(client.executable, existing)}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (managed && !isOwnedRegistration(clientName, managed)) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`The managed ${clientName} MCP registration points to a different bridge. Remove it explicitly before changing bridge paths.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (managed) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Refusing to recreate the missing ${clientName} MCP server named ${SERVER_NAME}: installer state says it was previously managed. Remove the stale installer state explicitly, then rerun the installer.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
plans.push({ clientName, client, action: 'add', registration });
|
|
114
|
+
}
|
|
115
|
+
return plans;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function applyMcpRegistrations(plans, commandRunner, transaction) {
|
|
119
|
+
const result = {};
|
|
120
|
+
for (const plan of plans) {
|
|
121
|
+
if (plan.action === 'add') {
|
|
122
|
+
await addRegistration(plan, plan.registration, commandRunner);
|
|
123
|
+
transaction.add(() => removeRegistration(plan, commandRunner));
|
|
124
|
+
} else if (plan.action === 'replace') {
|
|
125
|
+
await replaceRegistration(plan, commandRunner);
|
|
126
|
+
transaction.add(() => restoreRegistration(plan, commandRunner));
|
|
127
|
+
}
|
|
128
|
+
result[plan.clientName] = plan.registration;
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function replaceRegistration(plan, commandRunner) {
|
|
134
|
+
await removeRegistration(plan, commandRunner);
|
|
135
|
+
try {
|
|
136
|
+
await addRegistration(plan, plan.registration, commandRunner);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
try {
|
|
139
|
+
await addRegistration(plan, plan.previousRegistration, commandRunner);
|
|
140
|
+
} catch (restoreError) {
|
|
141
|
+
throw new AggregateError(
|
|
142
|
+
[error, restoreError],
|
|
143
|
+
`Failed to update and restore ${plan.clientName} MCP registration`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function restoreRegistration(plan, commandRunner) {
|
|
151
|
+
await removeRegistration(plan, commandRunner);
|
|
152
|
+
await addRegistration(plan, plan.previousRegistration, commandRunner);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function addRegistration(plan, registration, commandRunner) {
|
|
156
|
+
const added = await commandRunner(
|
|
157
|
+
plan.client.executable,
|
|
158
|
+
plan.client.addArgs(registration),
|
|
159
|
+
);
|
|
160
|
+
if (added.code !== 0) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Failed to register ${plan.clientName} MCP server. ${commandFailure(plan.client.executable, added)}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function removeRegistration(plan, commandRunner) {
|
|
168
|
+
const removed = await commandRunner(
|
|
169
|
+
plan.client.executable,
|
|
170
|
+
plan.client.removeArgs,
|
|
171
|
+
);
|
|
172
|
+
if (removed.code !== 0) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Failed to remove ${plan.clientName} MCP registration. ${commandFailure(plan.client.executable, removed)}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function environmentArgs(registration) {
|
|
180
|
+
return registration.apiUrl
|
|
181
|
+
? ['--env', `${API_URL_ENV}=${registration.apiUrl}`]
|
|
182
|
+
: [];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function createRegistration(clientName, values) {
|
|
186
|
+
return {
|
|
187
|
+
...values,
|
|
188
|
+
fingerprint: registrationFingerprint(clientName, values),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function registrationFingerprint(clientName, registration) {
|
|
193
|
+
return createHash('sha256')
|
|
194
|
+
.update(
|
|
195
|
+
JSON.stringify({
|
|
196
|
+
clientName,
|
|
197
|
+
nodePath: registration.nodePath,
|
|
198
|
+
bridgeEntry: registration.bridgeEntry,
|
|
199
|
+
apiUrl: registration.apiUrl ?? null,
|
|
200
|
+
}),
|
|
201
|
+
)
|
|
202
|
+
.digest('hex');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function sameRegistration(left, right) {
|
|
206
|
+
return (
|
|
207
|
+
left?.nodePath === right.nodePath &&
|
|
208
|
+
left?.bridgeEntry === right.bridgeEntry &&
|
|
209
|
+
left?.apiUrl === right.apiUrl &&
|
|
210
|
+
left?.fingerprint === right.fingerprint
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseExistingRegistration(clientName, stdout) {
|
|
215
|
+
try {
|
|
216
|
+
return clientName === 'codex'
|
|
217
|
+
? parseCodexRegistration(stdout)
|
|
218
|
+
: parseClaudeRegistration(stdout);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Could not safely inspect the existing ${clientName} MCP server ${SERVER_NAME}; refusing to modify it. ${error.message}`,
|
|
222
|
+
{ cause: error },
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function parseCodexRegistration(stdout) {
|
|
228
|
+
const document = parseJsonDocument(
|
|
229
|
+
stripAnsi(stdout).trim(),
|
|
230
|
+
'The native JSON output was malformed.',
|
|
231
|
+
);
|
|
232
|
+
const server = isRecord(document.server) ? document.server : document;
|
|
233
|
+
const transport = isRecord(server.transport) ? server.transport : server;
|
|
234
|
+
const type = transport.type ?? server.type;
|
|
235
|
+
if (type !== undefined && String(type).toLowerCase() !== 'stdio') {
|
|
236
|
+
throw new Error(`Expected a stdio transport, received ${String(type)}.`);
|
|
237
|
+
}
|
|
238
|
+
if (server.enabled === false) {
|
|
239
|
+
throw new Error('The registration is disabled.');
|
|
240
|
+
}
|
|
241
|
+
if (
|
|
242
|
+
transport.cwd !== undefined &&
|
|
243
|
+
transport.cwd !== null &&
|
|
244
|
+
transport.cwd !== ''
|
|
245
|
+
) {
|
|
246
|
+
throw new Error('A custom working directory is not installer-managed.');
|
|
247
|
+
}
|
|
248
|
+
const inheritedEnvironment = transport.env_vars ?? transport.envVars;
|
|
249
|
+
if (
|
|
250
|
+
inheritedEnvironment !== undefined &&
|
|
251
|
+
(!Array.isArray(inheritedEnvironment) || inheritedEnvironment.length > 0)
|
|
252
|
+
) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
'Inherited environment variables are not installer-managed.',
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return canonicalRegistrationFromValues({
|
|
258
|
+
command: transport.command,
|
|
259
|
+
args: transport.args,
|
|
260
|
+
env: transport.env,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function parseClaudeRegistration(stdout) {
|
|
265
|
+
const output = stripAnsi(stdout).replaceAll('\r\n', '\n').trim();
|
|
266
|
+
if (output.startsWith('{')) {
|
|
267
|
+
const document = parseJsonDocument(
|
|
268
|
+
output,
|
|
269
|
+
'The native JSON output was malformed.',
|
|
270
|
+
);
|
|
271
|
+
const server = isRecord(document.server) ? document.server : document;
|
|
272
|
+
const transport = isRecord(server.transport) ? server.transport : server;
|
|
273
|
+
return canonicalRegistrationFromValues({
|
|
274
|
+
command: transport.command,
|
|
275
|
+
args: transport.args,
|
|
276
|
+
env: transport.env,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const lines = output.split('\n');
|
|
281
|
+
if (!lines.some((line) => line.trim() === `${SERVER_NAME}:`)) {
|
|
282
|
+
throw new Error('The native output did not identify the requested server.');
|
|
283
|
+
}
|
|
284
|
+
const scope = readClaudeField(lines, 'Scope');
|
|
285
|
+
if (!/^user(?:\s+config|\s*\(|$)/i.test(scope)) {
|
|
286
|
+
throw new Error(`Expected user scope, received ${scope}.`);
|
|
287
|
+
}
|
|
288
|
+
const type = readClaudeField(lines, 'Type');
|
|
289
|
+
if (type.toLowerCase() !== 'stdio') {
|
|
290
|
+
throw new Error(`Expected a stdio transport, received ${type}.`);
|
|
291
|
+
}
|
|
292
|
+
const command = unquote(readClaudeField(lines, 'Command'));
|
|
293
|
+
const args = parseClaudeArgs(readClaudeField(lines, 'Args'));
|
|
294
|
+
const env = readClaudeEnvironment(lines);
|
|
295
|
+
return canonicalRegistrationFromValues({ command, args, env });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function readClaudeField(lines, label) {
|
|
299
|
+
const expression = new RegExp(`^\\s*${label}:\\s*(.*?)\\s*$`, 'i');
|
|
300
|
+
for (const line of lines) {
|
|
301
|
+
const match = line.match(expression);
|
|
302
|
+
if (match?.[1]) return match[1];
|
|
303
|
+
}
|
|
304
|
+
throw new Error(`The native output omitted ${label}.`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function parseClaudeArgs(value) {
|
|
308
|
+
if (/^(?:\(none\)|none|\[\])$/i.test(value.trim())) return [];
|
|
309
|
+
if (value.trim().startsWith('[')) {
|
|
310
|
+
const parsed = parseJsonDocument(value, 'Args contains malformed JSON.');
|
|
311
|
+
if (!Array.isArray(parsed)) throw new Error('Args is not an array.');
|
|
312
|
+
return parsed;
|
|
313
|
+
}
|
|
314
|
+
return [unquote(value.trim())];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function readClaudeEnvironment(lines) {
|
|
318
|
+
const environmentIndex = lines.findIndex((line) =>
|
|
319
|
+
/^\s*Environment:\s*/i.test(line),
|
|
320
|
+
);
|
|
321
|
+
if (environmentIndex < 0) {
|
|
322
|
+
throw new Error('The native output omitted Environment.');
|
|
323
|
+
}
|
|
324
|
+
const header = lines[environmentIndex];
|
|
325
|
+
const inline = header.replace(/^\s*Environment:\s*/i, '').trim();
|
|
326
|
+
if (inline && !/^(?:\(none\)|none|\{\})$/i.test(inline)) {
|
|
327
|
+
const parsed = parseJsonDocument(
|
|
328
|
+
inline,
|
|
329
|
+
'Environment contains malformed JSON.',
|
|
330
|
+
);
|
|
331
|
+
return normalizeEnvironment(parsed);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const headerIndent = header.length - header.trimStart().length;
|
|
335
|
+
const env = {};
|
|
336
|
+
for (const line of lines.slice(environmentIndex + 1)) {
|
|
337
|
+
if (!line.trim()) continue;
|
|
338
|
+
const indent = line.length - line.trimStart().length;
|
|
339
|
+
if (indent <= headerIndent) break;
|
|
340
|
+
const entry = line.trim();
|
|
341
|
+
const separator = entry.indexOf('=');
|
|
342
|
+
if (separator <= 0) {
|
|
343
|
+
throw new Error('Environment contains an unparseable entry.');
|
|
344
|
+
}
|
|
345
|
+
const key = entry.slice(0, separator).trim();
|
|
346
|
+
if (Object.hasOwn(env, key)) {
|
|
347
|
+
throw new Error(`Environment contains duplicate key ${key}.`);
|
|
348
|
+
}
|
|
349
|
+
env[key] = unquote(entry.slice(separator + 1).trim());
|
|
350
|
+
}
|
|
351
|
+
return env;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function canonicalRegistration(registration) {
|
|
355
|
+
return canonicalRegistrationFromValues({
|
|
356
|
+
command: registration.nodePath,
|
|
357
|
+
args: [registration.bridgeEntry],
|
|
358
|
+
env:
|
|
359
|
+
registration.apiUrl === undefined
|
|
360
|
+
? {}
|
|
361
|
+
: { [API_URL_ENV]: registration.apiUrl },
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function canonicalRegistrationFromValues({ command, args = [], env = {} }) {
|
|
366
|
+
if (typeof command !== 'string' || command.length === 0) {
|
|
367
|
+
throw new Error('Command must be a non-empty string.');
|
|
368
|
+
}
|
|
369
|
+
if (!Array.isArray(args) || args.some((value) => typeof value !== 'string')) {
|
|
370
|
+
throw new Error('Args must be an array of strings.');
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
command,
|
|
374
|
+
args: [...args],
|
|
375
|
+
env: normalizeEnvironment(env),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function normalizeEnvironment(environment) {
|
|
380
|
+
if (environment === null || environment === undefined) return {};
|
|
381
|
+
const entries = Array.isArray(environment)
|
|
382
|
+
? environment.map((entry) => {
|
|
383
|
+
if (typeof entry !== 'string') {
|
|
384
|
+
throw new Error('Environment entries must be strings.');
|
|
385
|
+
}
|
|
386
|
+
const separator = entry.indexOf('=');
|
|
387
|
+
if (separator <= 0) {
|
|
388
|
+
throw new Error('Environment entries must use KEY=value format.');
|
|
389
|
+
}
|
|
390
|
+
return [entry.slice(0, separator), entry.slice(separator + 1)];
|
|
391
|
+
})
|
|
392
|
+
: isRecord(environment)
|
|
393
|
+
? Object.entries(environment)
|
|
394
|
+
: null;
|
|
395
|
+
if (!entries) throw new Error('Environment must be an object or array.');
|
|
396
|
+
|
|
397
|
+
const normalized = {};
|
|
398
|
+
for (const [key, value] of entries.sort(([left], [right]) =>
|
|
399
|
+
left.localeCompare(right),
|
|
400
|
+
)) {
|
|
401
|
+
if (!key || typeof value !== 'string') {
|
|
402
|
+
throw new Error('Environment keys and values must be strings.');
|
|
403
|
+
}
|
|
404
|
+
if (Object.hasOwn(normalized, key)) {
|
|
405
|
+
throw new Error(`Environment contains duplicate key ${key}.`);
|
|
406
|
+
}
|
|
407
|
+
normalized[key] = value;
|
|
408
|
+
}
|
|
409
|
+
return normalized;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function sameCanonicalRegistration(left, right) {
|
|
413
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function parseJsonDocument(value, message) {
|
|
417
|
+
try {
|
|
418
|
+
return JSON.parse(value);
|
|
419
|
+
} catch {
|
|
420
|
+
throw new Error(message);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function stripAnsi(value) {
|
|
425
|
+
return String(value).replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function unquote(value) {
|
|
429
|
+
if (
|
|
430
|
+
value.length >= 2 &&
|
|
431
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
432
|
+
(value.startsWith("'") && value.endsWith("'")))
|
|
433
|
+
) {
|
|
434
|
+
return value.slice(1, -1);
|
|
435
|
+
}
|
|
436
|
+
return value;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function isRecord(value) {
|
|
440
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function isOwnedRegistration(clientName, managed) {
|
|
444
|
+
if (!managed) return false;
|
|
445
|
+
if (managed.apiUrl === undefined && managed.fingerprint === undefined) {
|
|
446
|
+
return (
|
|
447
|
+
typeof managed.nodePath === 'string' &&
|
|
448
|
+
typeof managed.bridgeEntry === 'string'
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
return (
|
|
452
|
+
typeof managed.apiUrl === 'string' &&
|
|
453
|
+
managed.fingerprint === registrationFingerprint(clientName, managed)
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export const mcpServerName = SERVER_NAME;
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "engineering-memory",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Installs the Engineering Memory skill and its local MCP bridge. Sign in after installing; your organization and project are resolved from your account.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"engineering-memory": "bin/engineering-memory.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"dispatcher",
|
|
16
|
+
"install",
|
|
17
|
+
"skill",
|
|
18
|
+
"runtime"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
22
|
+
"minimatch": "10.2.6",
|
|
23
|
+
"zod": "4.4.3"
|
|
24
|
+
},
|
|
25
|
+
"engineeringMemory": {
|
|
26
|
+
"apiUrl": "https://coral-app-zqmj6.ondigitalocean.app"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { endpoints } from '../config.js';
|
|
4
|
+
export class BrowserAuthCoordinator {
|
|
5
|
+
client;
|
|
6
|
+
credentials;
|
|
7
|
+
server = null;
|
|
8
|
+
pending = null;
|
|
9
|
+
constructor(client, credentials) {
|
|
10
|
+
this.client = client;
|
|
11
|
+
this.credentials = credentials;
|
|
12
|
+
}
|
|
13
|
+
async ensureAuthenticated(options = {}) {
|
|
14
|
+
if (await this.credentials.get('access-token')) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (options.restart) {
|
|
18
|
+
await this.discardPendingAuthentication();
|
|
19
|
+
}
|
|
20
|
+
if (this.pending && Date.parse(this.pending.expiresAt) > Date.now()) {
|
|
21
|
+
return {
|
|
22
|
+
authenticationRequired: true,
|
|
23
|
+
authorizationUrl: this.pending.authorizationUrl,
|
|
24
|
+
expiresAt: this.pending.expiresAt,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
await this.discardPendingAuthentication();
|
|
28
|
+
const codeVerifier = randomBytes(32).toString('base64url');
|
|
29
|
+
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
|
|
30
|
+
try {
|
|
31
|
+
const callbackUrl = await this.startCallbackServer();
|
|
32
|
+
const envelope = await this.client.request(endpoints.authBrowserStart, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
authenticated: false,
|
|
35
|
+
body: {
|
|
36
|
+
codeChallenge,
|
|
37
|
+
callbackUrl,
|
|
38
|
+
clientName: 'Engineering Memory MCP Bridge',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
if (!isBrowserAuthStart(envelope.data)) {
|
|
42
|
+
throw new Error('Browser authentication start response is invalid');
|
|
43
|
+
}
|
|
44
|
+
this.pending = {
|
|
45
|
+
requestId: envelope.data.requestId,
|
|
46
|
+
codeVerifier,
|
|
47
|
+
authorizationUrl: envelope.data.authorizationUrl,
|
|
48
|
+
expiresAt: envelope.data.expiresAt,
|
|
49
|
+
};
|
|
50
|
+
await this.credentials.set('browser-session', JSON.stringify(this.pending));
|
|
51
|
+
return {
|
|
52
|
+
authenticationRequired: true,
|
|
53
|
+
authorizationUrl: this.pending.authorizationUrl,
|
|
54
|
+
expiresAt: this.pending.expiresAt,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
await this.discardPendingAuthentication();
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async status() {
|
|
63
|
+
if (await this.credentials.get('access-token')) {
|
|
64
|
+
return { authenticated: true, pending: false };
|
|
65
|
+
}
|
|
66
|
+
if (this.pending && Date.parse(this.pending.expiresAt) > Date.now()) {
|
|
67
|
+
return {
|
|
68
|
+
authenticated: false,
|
|
69
|
+
pending: true,
|
|
70
|
+
authorizationUrl: this.pending.authorizationUrl,
|
|
71
|
+
expiresAt: this.pending.expiresAt,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (this.pending) {
|
|
75
|
+
await this.discardPendingAuthentication();
|
|
76
|
+
}
|
|
77
|
+
return { authenticated: false, pending: false };
|
|
78
|
+
}
|
|
79
|
+
async dispose() {
|
|
80
|
+
await this.discardPendingAuthentication();
|
|
81
|
+
}
|
|
82
|
+
async startCallbackServer() {
|
|
83
|
+
this.server = createServer((request, response) => {
|
|
84
|
+
void this.handleCallback(request.url ?? '/', response);
|
|
85
|
+
});
|
|
86
|
+
await new Promise((resolvePromise, reject) => {
|
|
87
|
+
this.server?.once('error', reject);
|
|
88
|
+
this.server?.listen(0, '127.0.0.1', resolvePromise);
|
|
89
|
+
});
|
|
90
|
+
const address = this.server.address();
|
|
91
|
+
return `http://127.0.0.1:${address.port}/callback`;
|
|
92
|
+
}
|
|
93
|
+
async handleCallback(requestUrl, response) {
|
|
94
|
+
try {
|
|
95
|
+
const url = new URL(requestUrl, 'http://127.0.0.1');
|
|
96
|
+
const requestId = url.searchParams.get('requestId');
|
|
97
|
+
const authorizationCode = url.searchParams.get('code');
|
|
98
|
+
if (!this.pending ||
|
|
99
|
+
requestId !== this.pending.requestId ||
|
|
100
|
+
!authorizationCode ||
|
|
101
|
+
Date.parse(this.pending.expiresAt) <= Date.now()) {
|
|
102
|
+
response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
103
|
+
response.end('<!doctype html><html><body>Authentication request is invalid.</body></html>');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const envelope = await this.client.request(endpoints.authBrowserExchange, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
authenticated: false,
|
|
109
|
+
body: {
|
|
110
|
+
requestId,
|
|
111
|
+
authorizationCode,
|
|
112
|
+
codeVerifier: this.pending.codeVerifier,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
if (!isTokenPair(envelope.data)) {
|
|
116
|
+
throw new Error('Browser authentication exchange response is invalid');
|
|
117
|
+
}
|
|
118
|
+
await this.persistTokenPair(envelope.data.accessToken, envelope.data.refreshToken);
|
|
119
|
+
this.pending = null;
|
|
120
|
+
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
121
|
+
response.end('<!doctype html><html><body>Authentication completed. You can return to the coding agent.</body></html>');
|
|
122
|
+
await this.stopServer();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
await this.clearCredentialsBestEffort();
|
|
126
|
+
this.pending = null;
|
|
127
|
+
response.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
128
|
+
response.end('<!doctype html><html><body>Authentication could not be completed.</body></html>');
|
|
129
|
+
await this.stopServer();
|
|
130
|
+
process.stderr.write('Browser authentication failed\n');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async persistTokenPair(accessToken, refreshToken) {
|
|
134
|
+
try {
|
|
135
|
+
await this.credentials.set('refresh-token', refreshToken);
|
|
136
|
+
await this.credentials.set('access-token', accessToken);
|
|
137
|
+
await this.credentials.delete('browser-session');
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
await this.clearCredentialsBestEffort();
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async discardPendingAuthentication() {
|
|
145
|
+
this.pending = null;
|
|
146
|
+
try {
|
|
147
|
+
await this.credentials.delete('browser-session');
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
process.stderr.write('Browser authentication state cleanup failed\n');
|
|
151
|
+
}
|
|
152
|
+
await this.stopServer();
|
|
153
|
+
}
|
|
154
|
+
async clearCredentialsBestEffort() {
|
|
155
|
+
try {
|
|
156
|
+
await this.credentials.clear();
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
process.stderr.write('Browser authentication credential rollback failed\n');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async stopServer() {
|
|
163
|
+
const server = this.server;
|
|
164
|
+
this.server = null;
|
|
165
|
+
if (!server) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
await new Promise((resolvePromise) => {
|
|
169
|
+
server.close(() => resolvePromise());
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function isBrowserAuthStart(value) {
|
|
174
|
+
return (value !== null &&
|
|
175
|
+
typeof value.requestId === 'string' &&
|
|
176
|
+
typeof value.authorizationUrl === 'string' &&
|
|
177
|
+
typeof value.expiresAt === 'string');
|
|
178
|
+
}
|
|
179
|
+
function isTokenPair(value) {
|
|
180
|
+
return (value !== null &&
|
|
181
|
+
typeof value.accessToken === 'string' &&
|
|
182
|
+
typeof value.refreshToken === 'string');
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=browser-auth.js.map
|