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/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 { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
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
- let parsed: unknown;
73
- try {
74
- parsed = JSON.parse(await readFile(publishStatePath(), 'utf8'));
75
- } catch (error) {
76
- const code = (error as NodeJS.ErrnoException).code;
77
- if (code === 'ENOENT') return undefined;
78
- if (error instanceof SyntaxError) {
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
- const entry = (parsed as StateFile | null)?.relics?.[relicId];
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 entry !== 'object' ||
97
- typeof entry.key !== 'string' ||
98
- typeof entry.publish_token !== 'string' ||
99
- typeof entry.version !== 'number' ||
100
- !Number.isSafeInteger(entry.version) ||
101
- entry.version < 1
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
- 'this relic id.'
302
+ `relic ${relicId}.`
106
303
  );
107
304
  }
108
- return entry;
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 relics = { ...existing?.relics, [relicId]: state };
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({ relics }, null, 2)}\n`, {
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 (parsed === null || typeof parsed !== 'object') {
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;