relic-mcp 0.3.0 → 0.3.2
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/relic-mcp.js +352 -36
- package/package.json +1 -1
- package/skills/relic/SKILL.md +30 -6
- package/src/publish.ts +115 -1
- package/src/server.ts +210 -12
- package/src/state.ts +312 -33
package/src/state.ts
CHANGED
|
@@ -15,9 +15,36 @@
|
|
|
15
15
|
* or the token into a message, a log line, or an error.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { execFile } from 'node:child_process';
|
|
19
|
+
import {
|
|
20
|
+
mkdir,
|
|
21
|
+
readFile,
|
|
22
|
+
realpath,
|
|
23
|
+
rename,
|
|
24
|
+
stat,
|
|
25
|
+
writeFile,
|
|
26
|
+
} from 'node:fs/promises';
|
|
19
27
|
import { homedir } from 'node:os';
|
|
20
|
-
import { dirname, join } from 'node:path';
|
|
28
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { promisify } from 'node:util';
|
|
31
|
+
|
|
32
|
+
export type SourceIdentityKind = 'git_remote' | 'git_common_dir' | 'realpath';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A stable name for one source file, plus the facts used to explain a match.
|
|
36
|
+
*
|
|
37
|
+
* The identity is an on-disk index key. The description is safe to return to
|
|
38
|
+
* the publisher: it contains paths and repository locations, never a content
|
|
39
|
+
* key or publish token.
|
|
40
|
+
*/
|
|
41
|
+
export interface SourceIdentity {
|
|
42
|
+
readonly identity: string;
|
|
43
|
+
readonly kind: SourceIdentityKind;
|
|
44
|
+
readonly path: string;
|
|
45
|
+
readonly repository?: string | undefined;
|
|
46
|
+
readonly description: string;
|
|
47
|
+
}
|
|
21
48
|
|
|
22
49
|
/** What this machine remembers about one published relic. */
|
|
23
50
|
export interface PublishState {
|
|
@@ -30,10 +57,24 @@ export interface PublishState {
|
|
|
30
57
|
readonly publish_token: string;
|
|
31
58
|
/** How many versions this machine has published; 1 after the first. */
|
|
32
59
|
readonly version: number;
|
|
60
|
+
/**
|
|
61
|
+
* Absent on state written before source lookup shipped. Absence is valid
|
|
62
|
+
* legacy state and means this entry cannot take part in prior-publish
|
|
63
|
+
* detection.
|
|
64
|
+
*/
|
|
65
|
+
readonly source?: SourceIdentity | undefined;
|
|
33
66
|
}
|
|
34
67
|
|
|
35
68
|
interface StateFile {
|
|
36
69
|
readonly relics: Record<string, PublishState>;
|
|
70
|
+
readonly sources?: Record<string, string> | undefined;
|
|
71
|
+
readonly [key: string]: unknown;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PublishedSource {
|
|
75
|
+
readonly relic_id: string;
|
|
76
|
+
readonly state: PublishState;
|
|
77
|
+
readonly source: SourceIdentity;
|
|
37
78
|
}
|
|
38
79
|
|
|
39
80
|
/**
|
|
@@ -57,6 +98,151 @@ export function publishStatePath(): string {
|
|
|
57
98
|
return join(configRoot, 'relic-mcp', 'publish-state.json');
|
|
58
99
|
}
|
|
59
100
|
|
|
101
|
+
const runFile = promisify(execFile);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Reduce equivalent Git transport URLs to one repository name.
|
|
105
|
+
*
|
|
106
|
+
* Credentials and transport do not identify a repository. Host and path do,
|
|
107
|
+
* and the hosts this client targets treat both without case.
|
|
108
|
+
*/
|
|
109
|
+
export function normalizeGitRemote(remote: string): string {
|
|
110
|
+
const value = remote.trim().replace(/^git\+/i, '');
|
|
111
|
+
try {
|
|
112
|
+
const url = new URL(value);
|
|
113
|
+
if (url.protocol === 'file:') {
|
|
114
|
+
return fileURLToPath(url)
|
|
115
|
+
.replaceAll('\\', '/')
|
|
116
|
+
.replace(/\/+$/g, '')
|
|
117
|
+
.replace(/\.git$/i, '');
|
|
118
|
+
}
|
|
119
|
+
if (url.hostname.length > 0) {
|
|
120
|
+
const host =
|
|
121
|
+
url.port.length > 0 ? `${url.hostname}:${url.port}` : url.hostname;
|
|
122
|
+
const path = url.pathname
|
|
123
|
+
.replace(/^\/+|\/+$/g, '')
|
|
124
|
+
.replace(/\.git$/i, '');
|
|
125
|
+
return `${host}/${path}`.toLowerCase();
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// SCP-style and local remotes are not URL syntax.
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const scp = value.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/);
|
|
132
|
+
if (scp?.[1] !== undefined && scp[2] !== undefined) {
|
|
133
|
+
const path = scp[2].replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '');
|
|
134
|
+
return `${scp[1]}/${path}`.toLowerCase();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return value
|
|
138
|
+
.replaceAll('\\', '/')
|
|
139
|
+
.replace(/\/+$/g, '')
|
|
140
|
+
.replace(/\.git$/i, '');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function gitOutput(
|
|
144
|
+
directory: string,
|
|
145
|
+
args: readonly string[]
|
|
146
|
+
): Promise<string | undefined> {
|
|
147
|
+
try {
|
|
148
|
+
const { stdout } = await runFile('git', ['-C', directory, ...args], {
|
|
149
|
+
encoding: 'utf8',
|
|
150
|
+
maxBuffer: 1024 * 1024,
|
|
151
|
+
});
|
|
152
|
+
const output = String(stdout).trim();
|
|
153
|
+
return output.length === 0 ? undefined : output;
|
|
154
|
+
} catch {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Name a source across sessions without binding it to one worktree.
|
|
161
|
+
*
|
|
162
|
+
* A repository remote is the strongest identity because it also crosses
|
|
163
|
+
* clones. A repository with no remote falls back to Git's shared common
|
|
164
|
+
* directory, which crosses worktrees from one clone. A file outside Git is
|
|
165
|
+
* named by realpath so symlink aliases do not create duplicate relics.
|
|
166
|
+
*/
|
|
167
|
+
export async function resolveSourceIdentity(
|
|
168
|
+
path: string
|
|
169
|
+
): Promise<SourceIdentity> {
|
|
170
|
+
const absolutePath = resolve(path);
|
|
171
|
+
const sourceDirectory = dirname(absolutePath);
|
|
172
|
+
const repositoryRoot = await gitOutput(sourceDirectory, [
|
|
173
|
+
'rev-parse',
|
|
174
|
+
'--show-toplevel',
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
if (repositoryRoot !== undefined) {
|
|
178
|
+
const repositoryPrefix = await gitOutput(sourceDirectory, [
|
|
179
|
+
'rev-parse',
|
|
180
|
+
'--show-prefix',
|
|
181
|
+
]);
|
|
182
|
+
const repositoryPath =
|
|
183
|
+
`${repositoryPrefix ?? ''}${basename(absolutePath)}`.replaceAll(
|
|
184
|
+
'\\',
|
|
185
|
+
'/'
|
|
186
|
+
);
|
|
187
|
+
const remoteNames = ((await gitOutput(repositoryRoot, ['remote'])) ?? '')
|
|
188
|
+
.split(/\r?\n/)
|
|
189
|
+
.filter((name) => name.length > 0)
|
|
190
|
+
.sort();
|
|
191
|
+
const remoteName = remoteNames.includes('origin')
|
|
192
|
+
? 'origin'
|
|
193
|
+
: remoteNames[0];
|
|
194
|
+
|
|
195
|
+
if (remoteName !== undefined) {
|
|
196
|
+
const remote = await gitOutput(repositoryRoot, [
|
|
197
|
+
'remote',
|
|
198
|
+
'get-url',
|
|
199
|
+
remoteName,
|
|
200
|
+
]);
|
|
201
|
+
if (remote !== undefined) {
|
|
202
|
+
const repository = normalizeGitRemote(remote);
|
|
203
|
+
return {
|
|
204
|
+
identity:
|
|
205
|
+
`git-remote:${encodeURIComponent(repository)}:` +
|
|
206
|
+
encodeURIComponent(repositoryPath),
|
|
207
|
+
kind: 'git_remote',
|
|
208
|
+
path: repositoryPath,
|
|
209
|
+
repository,
|
|
210
|
+
description: `${repositoryPath} in ${repository}`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const commonDirectory = await gitOutput(repositoryRoot, [
|
|
216
|
+
'rev-parse',
|
|
217
|
+
'--git-common-dir',
|
|
218
|
+
]);
|
|
219
|
+
if (commonDirectory !== undefined) {
|
|
220
|
+
const repository = await realpath(
|
|
221
|
+
isAbsolute(commonDirectory)
|
|
222
|
+
? commonDirectory
|
|
223
|
+
: resolve(repositoryRoot, commonDirectory)
|
|
224
|
+
);
|
|
225
|
+
return {
|
|
226
|
+
identity:
|
|
227
|
+
`git-common-dir:${encodeURIComponent(repository)}:` +
|
|
228
|
+
encodeURIComponent(repositoryPath),
|
|
229
|
+
kind: 'git_common_dir',
|
|
230
|
+
path: repositoryPath,
|
|
231
|
+
repository,
|
|
232
|
+
description: `${repositoryPath} in Git repository ${repository}`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const canonicalPath = await realpath(absolutePath);
|
|
238
|
+
return {
|
|
239
|
+
identity: `realpath:${encodeURIComponent(canonicalPath)}`,
|
|
240
|
+
kind: 'realpath',
|
|
241
|
+
path: canonicalPath,
|
|
242
|
+
description: canonicalPath,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
60
246
|
/**
|
|
61
247
|
* Writers queue behind one chain, so two publishes finishing close together
|
|
62
248
|
* cannot read-modify-write past each other and silently drop one relic's
|
|
@@ -69,43 +255,89 @@ let writeChain: Promise<void> = Promise.resolve();
|
|
|
69
255
|
export async function loadPublishState(
|
|
70
256
|
relicId: string
|
|
71
257
|
): Promise<PublishState | undefined> {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// The message names the file and stops. Echoing any of the bytes would
|
|
80
|
-
// put secrets into an error path, and a truncated tail of JSON is where
|
|
81
|
-
// a key or token is most likely to sit in one piece.
|
|
82
|
-
throw new Error(
|
|
83
|
-
`publish state at ${publishStatePath()} is not valid JSON. It cannot ` +
|
|
84
|
-
'be read, so relics recorded in it cannot be republished from this ' +
|
|
85
|
-
'machine until it is fixed or removed.'
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
throw new Error(
|
|
89
|
-
`could not read publish state at ${publishStatePath()}: ${code}`
|
|
90
|
-
);
|
|
258
|
+
const parsed = await readWholeFile(publishStatePath());
|
|
259
|
+
return validatePublishStateEntry(parsed?.relics?.[relicId], relicId);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isSourceIdentity(value: unknown): value is SourceIdentity {
|
|
263
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
264
|
+
return false;
|
|
91
265
|
}
|
|
266
|
+
const source = value as Record<string, unknown>;
|
|
267
|
+
return (
|
|
268
|
+
typeof source['identity'] === 'string' &&
|
|
269
|
+
(source['kind'] === 'git_remote' ||
|
|
270
|
+
source['kind'] === 'git_common_dir' ||
|
|
271
|
+
source['kind'] === 'realpath') &&
|
|
272
|
+
typeof source['path'] === 'string' &&
|
|
273
|
+
(source['repository'] === undefined ||
|
|
274
|
+
typeof source['repository'] === 'string') &&
|
|
275
|
+
typeof source['description'] === 'string'
|
|
276
|
+
);
|
|
277
|
+
}
|
|
92
278
|
|
|
93
|
-
|
|
279
|
+
function validatePublishStateEntry(
|
|
280
|
+
entry: unknown,
|
|
281
|
+
relicId: string
|
|
282
|
+
): PublishState | undefined {
|
|
94
283
|
if (entry === undefined) return undefined;
|
|
284
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`publish state at ${publishStatePath()} holds a malformed entry for ` +
|
|
287
|
+
`relic ${relicId}.`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
const candidate = entry as Record<string, unknown>;
|
|
95
291
|
if (
|
|
96
|
-
typeof
|
|
97
|
-
typeof
|
|
98
|
-
typeof
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
292
|
+
typeof candidate['key'] !== 'string' ||
|
|
293
|
+
typeof candidate['publish_token'] !== 'string' ||
|
|
294
|
+
typeof candidate['version'] !== 'number' ||
|
|
295
|
+
!Number.isSafeInteger(candidate['version']) ||
|
|
296
|
+
candidate['version'] < 1 ||
|
|
297
|
+
(candidate['source'] !== undefined &&
|
|
298
|
+
!isSourceIdentity(candidate['source']))
|
|
102
299
|
) {
|
|
103
300
|
throw new Error(
|
|
104
301
|
`publish state at ${publishStatePath()} holds a malformed entry for ` +
|
|
105
|
-
|
|
302
|
+
`relic ${relicId}.`
|
|
106
303
|
);
|
|
107
304
|
}
|
|
108
|
-
return
|
|
305
|
+
return candidate as unknown as PublishState;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Resolve a source through the reverse index without exposing its secrets. */
|
|
309
|
+
export async function loadPublishedSource(
|
|
310
|
+
source: SourceIdentity
|
|
311
|
+
): Promise<PublishedSource | undefined> {
|
|
312
|
+
const parsed = await readWholeFile(publishStatePath());
|
|
313
|
+
const sources = parsed?.sources;
|
|
314
|
+
if (sources === undefined) return undefined;
|
|
315
|
+
if (
|
|
316
|
+
sources === null ||
|
|
317
|
+
typeof sources !== 'object' ||
|
|
318
|
+
Array.isArray(sources)
|
|
319
|
+
) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`publish state at ${publishStatePath()} holds a malformed source index.`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const relicId = (sources as Record<string, unknown>)[source.identity];
|
|
326
|
+
if (relicId === undefined) return undefined;
|
|
327
|
+
if (typeof relicId !== 'string' || relicId.length === 0) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
`publish state at ${publishStatePath()} holds a malformed source match.`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const state = validatePublishStateEntry(parsed?.relics?.[relicId], relicId);
|
|
334
|
+
if (state?.source?.identity !== source.identity) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`publish state at ${publishStatePath()} holds a source match without ` +
|
|
337
|
+
'its relic entry.'
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
return { relic_id: relicId, state, source: state.source };
|
|
109
341
|
}
|
|
110
342
|
|
|
111
343
|
/**
|
|
@@ -134,13 +366,56 @@ async function writeEntry(relicId: string, state: PublishState): Promise<void> {
|
|
|
134
366
|
await mkdir(dir, { mode: 0o700, recursive: true });
|
|
135
367
|
|
|
136
368
|
const existing = await readWholeFile(path);
|
|
137
|
-
const
|
|
369
|
+
const storedRelics = existing?.['relics'];
|
|
370
|
+
if (
|
|
371
|
+
storedRelics !== undefined &&
|
|
372
|
+
(storedRelics === null ||
|
|
373
|
+
typeof storedRelics !== 'object' ||
|
|
374
|
+
Array.isArray(storedRelics))
|
|
375
|
+
) {
|
|
376
|
+
throw new Error(`publish state at ${path} holds malformed relic entries.`);
|
|
377
|
+
}
|
|
378
|
+
const oldEntry = (storedRelics as Record<string, unknown> | undefined)?.[
|
|
379
|
+
relicId
|
|
380
|
+
];
|
|
381
|
+
const relics = {
|
|
382
|
+
...(storedRelics as Record<string, unknown> | undefined),
|
|
383
|
+
[relicId]: {
|
|
384
|
+
...(oldEntry !== null &&
|
|
385
|
+
typeof oldEntry === 'object' &&
|
|
386
|
+
!Array.isArray(oldEntry)
|
|
387
|
+
? oldEntry
|
|
388
|
+
: {}),
|
|
389
|
+
...state,
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const storedSources = existing?.['sources'];
|
|
394
|
+
if (
|
|
395
|
+
storedSources !== undefined &&
|
|
396
|
+
(storedSources === null ||
|
|
397
|
+
typeof storedSources !== 'object' ||
|
|
398
|
+
Array.isArray(storedSources))
|
|
399
|
+
) {
|
|
400
|
+
throw new Error(`publish state at ${path} holds a malformed source index.`);
|
|
401
|
+
}
|
|
402
|
+
const sources = {
|
|
403
|
+
...(storedSources as Record<string, unknown> | undefined),
|
|
404
|
+
...(state.source === undefined ? {} : { [state.source.identity]: relicId }),
|
|
405
|
+
};
|
|
406
|
+
const next = {
|
|
407
|
+
...existing,
|
|
408
|
+
relics,
|
|
409
|
+
...(storedSources === undefined && state.source === undefined
|
|
410
|
+
? {}
|
|
411
|
+
: { sources }),
|
|
412
|
+
};
|
|
138
413
|
|
|
139
414
|
// Written to a sibling then renamed over, so a crash mid-write leaves the
|
|
140
415
|
// previous intact file rather than half of the new one. A torn file would
|
|
141
416
|
// take every relic's republish rights with it, not just the one in flight.
|
|
142
417
|
const temp = `${path}.tmp`;
|
|
143
|
-
await writeFile(temp, `${JSON.stringify(
|
|
418
|
+
await writeFile(temp, `${JSON.stringify(next, null, 2)}\n`, {
|
|
144
419
|
mode: 0o600,
|
|
145
420
|
});
|
|
146
421
|
await rename(temp, path);
|
|
@@ -158,7 +433,11 @@ async function readWholeFile(path: string): Promise<StateFile | undefined> {
|
|
|
158
433
|
|
|
159
434
|
try {
|
|
160
435
|
const parsed = JSON.parse(raw) as StateFile | null;
|
|
161
|
-
if (
|
|
436
|
+
if (
|
|
437
|
+
parsed === null ||
|
|
438
|
+
typeof parsed !== 'object' ||
|
|
439
|
+
Array.isArray(parsed)
|
|
440
|
+
) {
|
|
162
441
|
throw new Error('not an object');
|
|
163
442
|
}
|
|
164
443
|
return parsed;
|