@cassiomc1/forgeloop 1.10.2 → 1.11.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.
Files changed (84) hide show
  1. package/.cursor/rules/project-loop.mdc +2 -2
  2. package/.forgeloop/forgeloop.gitignore +1 -0
  3. package/.github/copilot-instructions.md +2 -2
  4. package/AGENTS.md +1 -0
  5. package/AGENT_COMPATIBILITY.md +7 -0
  6. package/CLAUDE.md +1 -0
  7. package/DOCS_INDEX.md +10 -0
  8. package/LOOP_SYSTEM_DESIGN.md +12 -0
  9. package/ORCHESTRATOR_INTEGRATION.md +9 -0
  10. package/PROTOCOL_INTEGRATION.md +17 -0
  11. package/README.md +12 -10
  12. package/THIRD_PARTY_NOTICES.md +11 -0
  13. package/THREAT_MODEL.md +21 -0
  14. package/benchmarks/repository-index/README.md +73 -0
  15. package/benchmarks/repository-index/queries.json +12 -0
  16. package/benchmarks/repository-index/run-hot-path.mjs +142 -0
  17. package/benchmarks/repository-index/run-persistent-transport.mjs +169 -0
  18. package/completions/_forgeloop +7 -1
  19. package/completions/forgeloop.bash +13 -1
  20. package/completions/forgeloop.fish +40 -1
  21. package/docs/AGENT_PROTOCOL_SUMMARY.md +8 -1
  22. package/docs/CLI_REFERENCE.md +114 -2
  23. package/docs/DOCUMENTATION_GUIDE.md +10 -5
  24. package/docs/GETTING_STARTED.md +17 -0
  25. package/docs/MCP.md +16 -1
  26. package/docs/PACKAGE_CONTENTS.md +7 -1
  27. package/docs/PERSISTENT_SEARCH_TRANSPORT.md +289 -0
  28. package/docs/RECIPES.md +30 -0
  29. package/docs/RELEASE_CHECKLIST.md +34 -0
  30. package/docs/REPOSITORY_INDEX.md +553 -0
  31. package/docs/TROUBLESHOOTING.md +147 -0
  32. package/docs/UNIVERSAL_INTEGRATION.md +30 -0
  33. package/docs/diagrams/README.md +10 -0
  34. package/package.json +11 -2
  35. package/scripts/update-tgrep-manifest.mjs +86 -0
  36. package/scripts/verify-tgrep-manifest.mjs +17 -0
  37. package/src/cli.js +35 -0
  38. package/src/commands/doctor.js +76 -1
  39. package/src/commands/index-rebuild.js +1 -0
  40. package/src/commands/index-setup.js +1 -0
  41. package/src/commands/index-start.js +1 -0
  42. package/src/commands/index-status.js +1 -0
  43. package/src/commands/index-stop.js +1 -0
  44. package/src/commands/init.js +39 -1
  45. package/src/commands/repository-index.js +111 -0
  46. package/src/commands/search.js +1 -0
  47. package/src/commands/update.js +32 -4
  48. package/src/core/cli-command-definitions.js +97 -3
  49. package/src/core/command-executors.js +35 -2
  50. package/src/core/command-input.js +23 -0
  51. package/src/core/error-codes.js +195 -0
  52. package/src/core/filesystem.js +10 -1
  53. package/src/core/integration-invocation-policy.js +27 -0
  54. package/src/core/integration-resources.js +16 -1
  55. package/src/core/protocol-info.js +23 -0
  56. package/src/integration.d.ts +90 -0
  57. package/src/integration.js +21 -0
  58. package/src/persistent-transport/client.js +293 -0
  59. package/src/persistent-transport/constants.js +24 -0
  60. package/src/persistent-transport/errors.js +38 -0
  61. package/src/persistent-transport/framing.js +61 -0
  62. package/src/persistent-transport/lifecycle.js +116 -0
  63. package/src/persistent-transport/ownership.js +184 -0
  64. package/src/persistent-transport/paths.js +31 -0
  65. package/src/persistent-transport/protocol.js +95 -0
  66. package/src/persistent-transport/server.js +256 -0
  67. package/src/persistent-transport/state.js +49 -0
  68. package/src/repository-index/args.js +59 -0
  69. package/src/repository-index/binary-manager.js +413 -0
  70. package/src/repository-index/constants.js +45 -0
  71. package/src/repository-index/errors.js +38 -0
  72. package/src/repository-index/lifecycle.js +17 -0
  73. package/src/repository-index/lock.js +113 -0
  74. package/src/repository-index/manifest.js +132 -0
  75. package/src/repository-index/metrics.js +30 -0
  76. package/src/repository-index/normalize-json.js +187 -0
  77. package/src/repository-index/paths.js +39 -0
  78. package/src/repository-index/platform.js +20 -0
  79. package/src/repository-index/process.js +140 -0
  80. package/src/repository-index/readiness.js +62 -0
  81. package/src/repository-index/search.js +262 -0
  82. package/src/repository-index/server.js +432 -0
  83. package/src/repository-index/status.js +397 -0
  84. package/src/repository-index/tgrep-manifest.json +38 -0
@@ -0,0 +1,413 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { execFile as nodeExecFile } from "node:child_process";
3
+ import { createReadStream } from "node:fs";
4
+ import {
5
+ access,
6
+ chmod,
7
+ constants as fsConstants,
8
+ copyFile,
9
+ mkdir,
10
+ mkdtemp,
11
+ readdir,
12
+ lstat,
13
+ readFile,
14
+ rename,
15
+ rm,
16
+ stat,
17
+ writeFile,
18
+ } from "node:fs/promises";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+
22
+ import { REPOSITORY_INDEX_DEFAULTS, REPOSITORY_INDEX_ENV_BINARY } from "./constants.js";
23
+ import { REPOSITORY_INDEX_ERROR_CODES, repositoryIndexError } from "./errors.js";
24
+ import { getRepositoryIndexPlatform } from "./platform.js";
25
+ import { getManagedTgrepBinaryPath, getManagedTgrepDirectory, getEngineHome } from "./paths.js";
26
+ import { getTgrepAsset, loadTgrepManifest } from "./manifest.js";
27
+ import { createTgrepBinaryHandle, runTgrep } from "./process.js";
28
+ import { acquireRepositoryIndexLock } from "./lock.js";
29
+
30
+ const VERSION_PATTERN = /\btgrep\s+(?:v)?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/i;
31
+ const ALLOWED_DOWNLOAD_HOSTS = new Set([
32
+ "github.com",
33
+ "objects.githubusercontent.com",
34
+ "release-assets.githubusercontent.com",
35
+ ]);
36
+
37
+ function assertTrustedDownloadUrl(value) {
38
+ let parsed;
39
+ try { parsed = new URL(value); } catch (cause) {
40
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_DOWNLOAD_FAILED, "Pinned tgrep asset URL is invalid", { cause, url: value });
41
+ }
42
+ if (parsed.protocol !== "https:" || !ALLOWED_DOWNLOAD_HOSTS.has(parsed.hostname)) {
43
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_DOWNLOAD_FAILED, `Pinned tgrep asset URL is not trusted: ${parsed.hostname}`, { url: value });
44
+ }
45
+ return parsed;
46
+ }
47
+
48
+ function absolutePath(value, label) {
49
+ if (typeof value !== "string" || !path.isAbsolute(value)) {
50
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_MISSING, `${label} must be an absolute path`);
51
+ }
52
+ return path.normalize(value);
53
+ }
54
+
55
+ async function ensureRegularFile(filePath, code, label) {
56
+ let info;
57
+ try {
58
+ info = await lstat(filePath);
59
+ } catch (cause) {
60
+ if (cause.code === "ENOENT") throw repositoryIndexError(code, `${label} is missing: ${filePath}`, { cause, path: filePath });
61
+ throw repositoryIndexError(code, `${label} cannot be inspected: ${filePath}: ${cause.message}`, { cause, path: filePath });
62
+ }
63
+ if (info.isSymbolicLink() || !info.isFile()) {
64
+ throw repositoryIndexError(code, `${label} must be a regular file: ${filePath}`, { path: filePath });
65
+ }
66
+ return info;
67
+ }
68
+
69
+ async function verifyExecutable(filePath, { platform = process.platform } = {}) {
70
+ await ensureRegularFile(filePath, REPOSITORY_INDEX_ERROR_CODES.ENGINE_MISSING, "Managed tgrep binary");
71
+ if (platform !== "win32") {
72
+ try {
73
+ await access(filePath, fsConstants.X_OK);
74
+ } catch (cause) {
75
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_MISSING, `Managed tgrep binary is not executable: ${filePath}`, { cause, path: filePath });
76
+ }
77
+ }
78
+ }
79
+
80
+ function parseVersion(output) {
81
+ return output.match(VERSION_PATTERN)?.[1] ?? null;
82
+ }
83
+
84
+ export async function verifyTgrepVersion({ binaryPath, expectedVersion, repoRoot = process.cwd(), spawnImpl, timeoutMs = 15_000 } = {}) {
85
+ const result = await runTgrep({
86
+ binary: createTgrepBinaryHandle(binaryPath),
87
+ repoRoot,
88
+ args: ["--version"],
89
+ spawnImpl,
90
+ timeoutMs,
91
+ maxOutputBytes: 64 * 1024,
92
+ });
93
+ if (result.timedOut || result.exitCode !== 0) {
94
+ throw repositoryIndexError(
95
+ REPOSITORY_INDEX_ERROR_CODES.ENGINE_EXECUTION_FAILED,
96
+ `Unable to verify tgrep version at ${binaryPath}`,
97
+ { result: { exitCode: result.exitCode, stderr: result.stderr.slice(0, 2000), timedOut: result.timedOut } },
98
+ );
99
+ }
100
+ const actualVersion = parseVersion(`${result.stdout}\n${result.stderr}`);
101
+ if (actualVersion !== expectedVersion) {
102
+ throw repositoryIndexError(
103
+ REPOSITORY_INDEX_ERROR_CODES.ENGINE_VERSION_MISMATCH,
104
+ `Managed tgrep version is ${actualVersion ?? "unknown"}; expected ${expectedVersion}`,
105
+ { actualVersion, expectedVersion, binaryPath },
106
+ );
107
+ }
108
+ return actualVersion;
109
+ }
110
+
111
+ export async function verifyManagedTgrep({ packageRoot, repoRoot = process.cwd(), platform, arch, env = process.env, binaryPath: explicitBinaryPath, homeDirectory = os.homedir(), spawnImpl } = {}) {
112
+ const manifest = await loadTgrepManifest(packageRoot);
113
+ const selectedPlatform = platform && arch
114
+ ? getRepositoryIndexPlatform({ platform, arch })
115
+ : getRepositoryIndexPlatform();
116
+ const override = explicitBinaryPath ?? env?.[REPOSITORY_INDEX_ENV_BINARY];
117
+ if (override) {
118
+ const overridePath = absolutePath(override, REPOSITORY_INDEX_ENV_BINARY);
119
+ await ensureRegularFile(overridePath, REPOSITORY_INDEX_ERROR_CODES.ENGINE_MISSING, "Explicit tgrep override");
120
+ if (selectedPlatform.platform !== "win32") {
121
+ try { await access(overridePath, fsConstants.X_OK); } catch (cause) {
122
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_MISSING, `Explicit tgrep override is not executable: ${overridePath}`, { cause, path: overridePath });
123
+ }
124
+ }
125
+ const version = await verifyTgrepVersion({ binaryPath: overridePath, expectedVersion: manifest.version, repoRoot, spawnImpl });
126
+ return Object.freeze({
127
+ engine: manifest.engine,
128
+ engineVersion: version,
129
+ platform: selectedPlatform,
130
+ binaryPath: overridePath,
131
+ managed: false,
132
+ overridden: true,
133
+ canonical: false,
134
+ asset: null,
135
+ });
136
+ }
137
+
138
+ const binaryPath = getManagedTgrepBinaryPath(manifest.version, selectedPlatform.key, {
139
+ homeDirectory,
140
+ windows: selectedPlatform.platform === "win32",
141
+ });
142
+ const asset = manifest.assets[selectedPlatform.key];
143
+ await verifyExecutable(binaryPath, { platform: selectedPlatform.platform });
144
+ await verifyBinaryChecksum(binaryPath, asset.binarySha256);
145
+ const version = await verifyTgrepVersion({ binaryPath, expectedVersion: manifest.version, repoRoot, spawnImpl });
146
+ return Object.freeze({
147
+ engine: manifest.engine,
148
+ engineVersion: version,
149
+ platform: selectedPlatform,
150
+ binaryPath,
151
+ managed: true,
152
+ overridden: false,
153
+ canonical: true,
154
+ asset,
155
+ });
156
+ }
157
+
158
+ function execFileAsync(execFileImpl, file, args, options = {}) {
159
+ return new Promise((resolve, reject) => {
160
+ execFileImpl(file, args, { shell: false, ...options }, (error, stdout, stderr) => {
161
+ if (error) {
162
+ error.stdout = stdout;
163
+ error.stderr = stderr;
164
+ reject(error);
165
+ return;
166
+ }
167
+ resolve({ stdout, stderr });
168
+ });
169
+ });
170
+ }
171
+
172
+ export function validateArchiveEntry(entry) {
173
+ const portable = entry.replaceAll("\\", "/").replace(/^\.\//, "") || ".";
174
+ if (portable.includes("\0") || path.posix.isAbsolute(portable) || path.win32.isAbsolute(portable)) {
175
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_EXTRACTION_FAILED, `Archive contains an absolute path: ${entry}`);
176
+ }
177
+ const normalized = path.posix.normalize(portable);
178
+ if (normalized === ".." || normalized.startsWith("../")) {
179
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_EXTRACTION_FAILED, `Archive path escapes extraction directory: ${entry}`);
180
+ }
181
+ return normalized;
182
+ }
183
+
184
+ async function listArchiveEntries(archivePath, { archive = "tar.gz", execFileImpl = nodeExecFile } = {}) {
185
+ const listCommand = archive === "zip" && process.platform !== "win32" ? "unzip" : "tar";
186
+ const listArgs = listCommand === "unzip" ? ["-Z1", archivePath] : ["-tf", archivePath];
187
+ try {
188
+ const listed = await execFileAsync(execFileImpl, listCommand, listArgs, { maxBuffer: 2 * 1024 * 1024 });
189
+ return listed.stdout.split(/\r?\n/).filter(Boolean).map(validateArchiveEntry);
190
+ } catch (cause) {
191
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_EXTRACTION_FAILED, `Unable to inspect tgrep archive: ${cause.message}`, { cause });
192
+ }
193
+ }
194
+
195
+ async function extractArchive(archivePath, extractionDirectory, { archive = "tar.gz", execFileImpl = nodeExecFile } = {}) {
196
+ await listArchiveEntries(archivePath, { archive, execFileImpl });
197
+ const extractCommand = archive === "zip" && process.platform !== "win32" ? "unzip" : "tar";
198
+ const extractArgs = extractCommand === "unzip"
199
+ ? ["-q", archivePath, "-d", extractionDirectory]
200
+ : ["-xf", archivePath, "-C", extractionDirectory];
201
+ try {
202
+ await execFileAsync(execFileImpl, extractCommand, extractArgs, { maxBuffer: 2 * 1024 * 1024 });
203
+ } catch (cause) {
204
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_EXTRACTION_FAILED, `Unable to extract tgrep archive: ${cause.message}`, { cause });
205
+ }
206
+ }
207
+
208
+ async function findBinary(root, binaryName) {
209
+ const found = [];
210
+ async function visit(directory) {
211
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
212
+ const entryPath = path.join(directory, entry.name);
213
+ if (entry.isSymbolicLink()) {
214
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_EXTRACTION_FAILED, `Archive contains a symbolic link: ${entry.name}`);
215
+ }
216
+ if (entry.isDirectory()) {
217
+ await visit(entryPath);
218
+ } else if (entry.isFile() && entry.name === binaryName) {
219
+ found.push(entryPath);
220
+ }
221
+ }
222
+ }
223
+ await visit(root);
224
+ if (found.length !== 1) {
225
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_EXTRACTION_FAILED, `Expected exactly one ${binaryName} in tgrep archive; found ${found.length}`);
226
+ }
227
+ return found[0];
228
+ }
229
+
230
+ export async function verifyArchiveChecksum(archivePath, expectedSha256) {
231
+ const actual = await sha256File(archivePath);
232
+ if (actual !== expectedSha256) {
233
+ throw repositoryIndexError(
234
+ REPOSITORY_INDEX_ERROR_CODES.ENGINE_CHECKSUM_MISMATCH,
235
+ `tgrep archive checksum mismatch: expected ${expectedSha256}, got ${actual}`,
236
+ { expectedSha256, actualSha256: actual, archivePath },
237
+ );
238
+ }
239
+ return actual;
240
+ }
241
+
242
+ export async function sha256File(filePath) {
243
+ return new Promise((resolve, reject) => {
244
+ const hash = createHash("sha256");
245
+ const stream = createReadStream(filePath);
246
+ stream.on("data", (chunk) => hash.update(chunk));
247
+ stream.on("error", reject);
248
+ stream.on("end", () => resolve(hash.digest("hex")));
249
+ });
250
+ }
251
+
252
+ export async function verifyBinaryChecksum(binaryPath, expectedSha256) {
253
+ const actual = await sha256File(binaryPath);
254
+ if (actual !== expectedSha256) {
255
+ throw repositoryIndexError(
256
+ REPOSITORY_INDEX_ERROR_CODES.ENGINE_BINARY_CHECKSUM_MISMATCH,
257
+ `tgrep binary checksum mismatch: expected ${expectedSha256}, got ${actual}`,
258
+ { expectedSha256, actualSha256: actual, binaryPath },
259
+ );
260
+ }
261
+ return actual;
262
+ }
263
+
264
+ async function downloadResponse(url, { fetchImpl = fetch, maxBytes = REPOSITORY_INDEX_DEFAULTS.maxAssetBytes } = {}) {
265
+ assertTrustedDownloadUrl(url);
266
+ let response;
267
+ try {
268
+ response = await fetchImpl(url, { redirect: "follow" });
269
+ } catch (cause) {
270
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_DOWNLOAD_FAILED, `Unable to download pinned tgrep asset: ${cause.message}`, { cause, url });
271
+ }
272
+ if (!response?.ok) {
273
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_DOWNLOAD_FAILED, `Pinned tgrep asset download returned HTTP ${response?.status ?? "unknown"}`, { url, status: response?.status ?? null });
274
+ }
275
+ if (response.url) {
276
+ assertTrustedDownloadUrl(response.url);
277
+ }
278
+ let bytes;
279
+ try {
280
+ bytes = Buffer.from(await response.arrayBuffer());
281
+ } catch (cause) {
282
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_DOWNLOAD_FAILED, `Unable to read pinned tgrep asset: ${cause.message}`, { cause, url });
283
+ }
284
+ if (bytes.byteLength > maxBytes) {
285
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_DOWNLOAD_FAILED, `Pinned tgrep asset exceeds ${maxBytes} bytes`, { url, maxBytes });
286
+ }
287
+ return bytes;
288
+ }
289
+
290
+ export async function downloadTgrepAsset({ asset, destinationDirectory, assetPath, fetchImpl, maxBytes = REPOSITORY_INDEX_DEFAULTS.maxAssetBytes } = {}) {
291
+ if (!asset || typeof asset.assetName !== "string") {
292
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_DOWNLOAD_FAILED, "A pinned tgrep asset descriptor is required");
293
+ }
294
+ await mkdir(destinationDirectory, { recursive: true });
295
+ const destination = path.join(destinationDirectory, asset.assetName);
296
+ let bytes;
297
+ if (assetPath) {
298
+ const source = absolutePath(assetPath, "--asset");
299
+ try {
300
+ await ensureRegularFile(source, REPOSITORY_INDEX_ERROR_CODES.ENGINE_DOWNLOAD_FAILED, "Preloaded tgrep asset");
301
+ const info = await stat(source);
302
+ if (info.size > maxBytes) throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_DOWNLOAD_FAILED, `Preloaded tgrep asset exceeds ${maxBytes} bytes`, { source, maxBytes });
303
+ bytes = await readFile(source);
304
+ } catch (cause) {
305
+ if (cause.code?.startsWith?.("E_REPOSITORY_INDEX")) throw cause;
306
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.ENGINE_DOWNLOAD_FAILED, `Unable to read preloaded tgrep asset: ${cause.message}`, { cause, source });
307
+ }
308
+ } else {
309
+ bytes = await downloadResponse(asset.url, { fetchImpl, maxBytes });
310
+ }
311
+ await writeFile(destination, bytes, { flag: "w" });
312
+ await verifyArchiveChecksum(destination, asset.sha256);
313
+ return destination;
314
+ }
315
+
316
+ function binaryDescriptor({ manifest, selectedPlatform, binaryPath, managed, overridden, asset }) {
317
+ return Object.freeze({
318
+ engine: manifest.engine,
319
+ engineVersion: manifest.version,
320
+ platform: selectedPlatform,
321
+ binaryPath,
322
+ managed,
323
+ overridden,
324
+ canonical: managed && !overridden,
325
+ asset: asset ?? null,
326
+ });
327
+ }
328
+
329
+ export async function ensureManagedTgrep({ packageRoot, repoRoot = process.cwd(), platform, arch, env = process.env, binaryPath: explicitBinaryPath, assetPath, homeDirectory = os.homedir(), fetchImpl, spawnImpl, execFileImpl } = {}) {
330
+ const manifest = await loadTgrepManifest(packageRoot);
331
+ const selectedPlatform = platform && arch
332
+ ? getRepositoryIndexPlatform({ platform, arch })
333
+ : getRepositoryIndexPlatform();
334
+ const override = explicitBinaryPath ?? env?.[REPOSITORY_INDEX_ENV_BINARY];
335
+ if (override) {
336
+ const verified = await verifyManagedTgrep({ packageRoot, repoRoot, platform: selectedPlatform.platform, arch: selectedPlatform.arch, env: { ...env, [REPOSITORY_INDEX_ENV_BINARY]: override }, binaryPath: override, spawnImpl });
337
+ return verified;
338
+ }
339
+
340
+ const binaryPath = getManagedTgrepBinaryPath(manifest.version, selectedPlatform.key, {
341
+ homeDirectory,
342
+ windows: selectedPlatform.platform === "win32",
343
+ });
344
+ const finalDirectory = getManagedTgrepDirectory(manifest.version, selectedPlatform.key, { homeDirectory });
345
+ const provisionLockPath = path.join(getEngineHome({ homeDirectory }), ".provision.lock");
346
+ const lease = await acquireRepositoryIndexLock(provisionLockPath, "tgrep-provision", { timeoutMs: 120_000 });
347
+ try {
348
+ let repairRequired = false;
349
+ try {
350
+ await verifyExecutable(binaryPath, { platform: selectedPlatform.platform });
351
+ await verifyBinaryChecksum(binaryPath, manifest.assets[selectedPlatform.key].binarySha256);
352
+ await verifyTgrepVersion({ binaryPath, expectedVersion: manifest.version, repoRoot, spawnImpl });
353
+ return binaryDescriptor({ manifest, selectedPlatform, binaryPath, managed: true, overridden: false, asset: manifest.assets[selectedPlatform.key] });
354
+ } catch (cause) {
355
+ if (cause.code !== REPOSITORY_INDEX_ERROR_CODES.ENGINE_MISSING
356
+ && cause.code !== REPOSITORY_INDEX_ERROR_CODES.ENGINE_VERSION_MISMATCH
357
+ && cause.code !== REPOSITORY_INDEX_ERROR_CODES.ENGINE_EXECUTION_FAILED
358
+ && cause.code !== REPOSITORY_INDEX_ERROR_CODES.ENGINE_BINARY_CHECKSUM_MISMATCH) throw cause;
359
+ repairRequired = true;
360
+ }
361
+
362
+ const asset = await getTgrepAsset(selectedPlatform.key, packageRoot);
363
+ const temporaryRoot = await mkdtemp(path.join(getEngineHome({ homeDirectory }), "tmp-"));
364
+ try {
365
+ const archivePath = await downloadTgrepAsset({
366
+ asset,
367
+ destinationDirectory: temporaryRoot,
368
+ assetPath,
369
+ fetchImpl,
370
+ });
371
+ await verifyArchiveChecksum(archivePath, asset.sha256);
372
+ const extractionDirectory = path.join(temporaryRoot, "extract");
373
+ await mkdir(extractionDirectory);
374
+ await extractArchive(archivePath, extractionDirectory, { archive: asset.archive, execFileImpl });
375
+ const extractedBinary = await findBinary(extractionDirectory, asset.binaryName);
376
+ await ensureRegularFile(extractedBinary, REPOSITORY_INDEX_ERROR_CODES.ENGINE_EXTRACTION_FAILED, "Extracted tgrep binary");
377
+ await verifyBinaryChecksum(extractedBinary, asset.binarySha256);
378
+ const stagedBinary = path.join(temporaryRoot, asset.binaryName);
379
+ await copyFile(extractedBinary, stagedBinary);
380
+ if (selectedPlatform.platform !== "win32") await chmod(stagedBinary, 0o755);
381
+ await mkdir(finalDirectory, { recursive: true });
382
+ const quarantinePath = `${binaryPath}.invalid-${randomUUID()}`;
383
+ let quarantined = false;
384
+ let installed = false;
385
+ try {
386
+ if (repairRequired) {
387
+ try {
388
+ await rename(binaryPath, quarantinePath);
389
+ quarantined = true;
390
+ } catch (cause) {
391
+ if (cause.code !== "ENOENT") throw cause;
392
+ }
393
+ }
394
+ await rename(stagedBinary, binaryPath);
395
+ installed = true;
396
+ if (selectedPlatform.platform !== "win32") await chmod(binaryPath, 0o755);
397
+ await verifyExecutable(binaryPath, { platform: selectedPlatform.platform });
398
+ await verifyBinaryChecksum(binaryPath, asset.binarySha256);
399
+ await verifyTgrepVersion({ binaryPath, expectedVersion: manifest.version, repoRoot, spawnImpl });
400
+ } catch (cause) {
401
+ if (installed) await rm(binaryPath, { force: true }).catch(() => {});
402
+ if (quarantined) await rename(quarantinePath, binaryPath).catch(() => {});
403
+ throw cause;
404
+ }
405
+ if (quarantined) await rm(quarantinePath, { force: true });
406
+ return binaryDescriptor({ manifest, selectedPlatform, binaryPath, managed: true, overridden: false, asset });
407
+ } finally {
408
+ await rm(temporaryRoot, { recursive: true, force: true });
409
+ }
410
+ } finally {
411
+ await lease.release();
412
+ }
413
+ }
@@ -0,0 +1,45 @@
1
+ export const REPOSITORY_INDEX_SCHEMA_VERSION = 1;
2
+ export const REPOSITORY_INDEX_ENGINE = "tgrep";
3
+ export const REPOSITORY_INDEX_REQUIRED = true;
4
+ export const REPOSITORY_INDEX_RELATIVE_PATH = ".forgeloop/repository-index";
5
+ export const REPOSITORY_INDEX_STATE_FILE = "engine-state.json";
6
+ export const REPOSITORY_INDEX_METRICS_DIRECTORY = "metrics";
7
+
8
+ export const REPOSITORY_INDEX_DEFAULTS = Object.freeze({
9
+ maxFileSize: "64M",
10
+ maxCpuPercent: 50,
11
+ watcherQueueCap: 16_384,
12
+ autoSaveMutations: 5_000,
13
+ startupTimeoutMs: 60_000,
14
+ commandTimeoutMs: 120_000,
15
+ maxProcessOutputBytes: 8 * 1024 * 1024,
16
+ maxAssetBytes: 100 * 1024 * 1024,
17
+ });
18
+
19
+ export const REPOSITORY_INDEX_SUPPORTED_PLATFORMS = Object.freeze([
20
+ "darwin-arm64",
21
+ "darwin-x64",
22
+ "linux-x64",
23
+ "windows-x64",
24
+ ]);
25
+
26
+ export const REPOSITORY_INDEX_SEARCH_EVENT_TYPES = Object.freeze([
27
+ "begin",
28
+ "match",
29
+ "context",
30
+ "end",
31
+ "summary",
32
+ ]);
33
+
34
+ export const REPOSITORY_INDEX_HEALTH = Object.freeze([
35
+ "READY",
36
+ "INDEXING",
37
+ "NOT_INITIALIZED",
38
+ "ENGINE_MISSING",
39
+ "ENGINE_INVALID",
40
+ "SERVER_DOWN",
41
+ "SERVER_UNHEALTHY",
42
+ "ERROR",
43
+ ]);
44
+
45
+ export const REPOSITORY_INDEX_ENV_BINARY = "FORGELOOP_TGREP_BINARY";
@@ -0,0 +1,38 @@
1
+ export const REPOSITORY_INDEX_ERROR_CODES = Object.freeze({
2
+ PLATFORM_UNSUPPORTED: "E_REPOSITORY_INDEX_PLATFORM_UNSUPPORTED",
3
+ ENGINE_MISSING: "E_REPOSITORY_INDEX_ENGINE_MISSING",
4
+ ENGINE_DOWNLOAD_FAILED: "E_REPOSITORY_INDEX_ENGINE_DOWNLOAD_FAILED",
5
+ ENGINE_CHECKSUM_MISMATCH: "E_REPOSITORY_INDEX_ENGINE_CHECKSUM_MISMATCH",
6
+ ENGINE_BINARY_CHECKSUM_MISMATCH: "E_REPOSITORY_INDEX_ENGINE_BINARY_CHECKSUM_MISMATCH",
7
+ ENGINE_EXTRACTION_FAILED: "E_REPOSITORY_INDEX_ENGINE_EXTRACTION_FAILED",
8
+ ENGINE_VERSION_MISMATCH: "E_REPOSITORY_INDEX_ENGINE_VERSION_MISMATCH",
9
+ ENGINE_EXECUTION_FAILED: "E_REPOSITORY_INDEX_ENGINE_EXECUTION_FAILED",
10
+ ENGINE_OUTPUT_LIMIT: "E_REPOSITORY_INDEX_OUTPUT_LIMIT",
11
+ NOT_INITIALIZED: "E_REPOSITORY_INDEX_NOT_INITIALIZED",
12
+ INDEXING: "E_REPOSITORY_INDEX_INDEXING",
13
+ SERVER_START_FAILED: "E_REPOSITORY_INDEX_SERVER_START_FAILED",
14
+ SERVER_STOP_FAILED: "E_REPOSITORY_INDEX_SERVER_STOP_FAILED",
15
+ SERVER_UNHEALTHY: "E_REPOSITORY_INDEX_SERVER_UNHEALTHY",
16
+ SEARCH_FAILED: "E_REPOSITORY_INDEX_SEARCH_FAILED",
17
+ OUTPUT_INVALID: "E_REPOSITORY_INDEX_OUTPUT_INVALID",
18
+ REBUILD_FAILED: "E_REPOSITORY_INDEX_REBUILD_FAILED",
19
+ REQUEST_INVALID: "E_REPOSITORY_INDEX_REQUEST_INVALID",
20
+ LOCK_UNSAFE: "E_REPOSITORY_INDEX_LOCK_UNSAFE",
21
+ });
22
+
23
+ export function repositoryIndexError(code, message, details = {}) {
24
+ const error = new Error(message);
25
+ error.code = code;
26
+ for (const [key, value] of Object.entries(details)) {
27
+ if (value !== undefined) error[key] = value;
28
+ }
29
+ return error;
30
+ }
31
+
32
+ export function wrapRepositoryIndexError(code, message, cause, details = {}) {
33
+ const error = repositoryIndexError(code, `${message}: ${cause?.message ?? cause}`, {
34
+ ...details,
35
+ cause,
36
+ });
37
+ return error;
38
+ }
@@ -0,0 +1,17 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Repository-index provisioning is only automatic for an actual repository.
6
+ * Keeping the candidate test narrow avoids turning ForgeLoop's fixture and
7
+ * protocol-only targets into implicit native-tool installations.
8
+ */
9
+ export async function isRepositoryCandidate(repositoryRoot) {
10
+ try {
11
+ const info = await lstat(path.join(repositoryRoot, ".git"));
12
+ return info.isDirectory() || info.isFile();
13
+ } catch (error) {
14
+ if (error.code === "ENOENT") return false;
15
+ throw error;
16
+ }
17
+ }
@@ -0,0 +1,113 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, rename, unlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { REPOSITORY_INDEX_ERROR_CODES, repositoryIndexError } from "./errors.js";
6
+
7
+ const LOCK_POLL_INTERVAL_MS = 50;
8
+ const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
9
+ const MAX_LOCK_TIMEOUT_MS = 120_000;
10
+
11
+ function readLock(lockPath) {
12
+ return readFile(lockPath, "utf8")
13
+ .then((value) => JSON.parse(value))
14
+ .catch((error) => {
15
+ if (error.code === "ENOENT") return null;
16
+ // A competing process can observe the tiny interval between creation
17
+ // of the exclusive lock file and completion of its metadata write. An
18
+ // unreadable lock is never stale-released; callers retry until the
19
+ // owner has published valid metadata or the bounded lease expires.
20
+ if (error instanceof SyntaxError) return { invalid: true };
21
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.LOCK_UNSAFE, `Repository Index lock cannot be read: ${lockPath}`, { cause: error });
22
+ });
23
+ }
24
+
25
+ function processIsAlive(pid) {
26
+ if (!Number.isInteger(pid) || pid <= 0) return false;
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ } catch (error) {
31
+ return error.code === "EPERM";
32
+ }
33
+ }
34
+
35
+ async function releaseDeadLock(lockPath, observed) {
36
+ if (observed?.invalid === true) return false;
37
+ if (observed && processIsAlive(observed.pid)) return false;
38
+ const quarantine = `${lockPath}.stale-${randomUUID()}`;
39
+ try {
40
+ await rename(lockPath, quarantine);
41
+ } catch (error) {
42
+ if (error.code === "ENOENT") return true;
43
+ throw error;
44
+ }
45
+ const current = await readLock(quarantine);
46
+ if (current?.invalid === true) {
47
+ await rename(quarantine, lockPath);
48
+ return false;
49
+ }
50
+ if (current && JSON.stringify(current) !== JSON.stringify(observed)) {
51
+ await rename(quarantine, lockPath);
52
+ return false;
53
+ }
54
+ await unlink(quarantine);
55
+ return true;
56
+ }
57
+
58
+ export async function acquireRepositoryIndexLock(lockPath, operation, { timeoutMs = DEFAULT_LOCK_TIMEOUT_MS, tryOnly = false } = {}) {
59
+ await mkdir(path.dirname(lockPath), { recursive: true });
60
+ const lock = {
61
+ schemaVersion: 1,
62
+ lockId: randomUUID(),
63
+ pid: process.pid,
64
+ operation,
65
+ acquiredAt: new Date().toISOString(),
66
+ };
67
+ const boundedTimeoutMs = Number.isSafeInteger(timeoutMs) && timeoutMs > 0
68
+ ? Math.min(timeoutMs, MAX_LOCK_TIMEOUT_MS)
69
+ : DEFAULT_LOCK_TIMEOUT_MS;
70
+ const deadline = Date.now() + boundedTimeoutMs;
71
+ while (true) {
72
+ let handle = null;
73
+ try {
74
+ handle = await open(lockPath, "wx");
75
+ await handle.writeFile(`${JSON.stringify(lock)}\n`, "utf8");
76
+ await handle.close();
77
+ return {
78
+ lock,
79
+ release: async () => {
80
+ const current = await readLock(lockPath);
81
+ if (current?.lockId === lock.lockId) await unlink(lockPath);
82
+ },
83
+ };
84
+ } catch (error) {
85
+ if (handle) {
86
+ try { await handle.close(); } catch { /* preserve original failure */ }
87
+ }
88
+ if (error.code !== "EEXIST") throw error;
89
+ if (tryOnly) {
90
+ const observed = await readLock(lockPath);
91
+ if (observed && await releaseDeadLock(lockPath, observed)) continue;
92
+ return null;
93
+ }
94
+ const observed = await readLock(lockPath);
95
+ if (!observed || !(await releaseDeadLock(lockPath, observed))) {
96
+ if (Date.now() >= deadline) {
97
+ throw repositoryIndexError(REPOSITORY_INDEX_ERROR_CODES.LOCK_UNSAFE, `Repository Index operation lock is busy: ${lockPath}`, { lock: observed });
98
+ }
99
+ await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_INTERVAL_MS));
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ export async function withRepositoryIndexLock(repoRoot, operation, callback, options = {}) {
106
+ const lockPath = options.lockPath ?? path.join(repoRoot, ".forgeloop", "repository-index", "operation.lock");
107
+ const lease = await acquireRepositoryIndexLock(lockPath, operation, options);
108
+ try {
109
+ return await callback(lease.lock);
110
+ } finally {
111
+ await lease.release();
112
+ }
113
+ }