@creeperhost/modlens-mcp 1.6.13 → 1.6.15

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 (66) hide show
  1. package/README.md +32 -2
  2. package/dist/feed-the-beast.d.ts +46 -0
  3. package/dist/feed-the-beast.d.ts.map +1 -0
  4. package/dist/feed-the-beast.js +29 -0
  5. package/dist/feed-the-beast.js.map +1 -0
  6. package/dist/generated/sqlite/browser.d.ts +10 -0
  7. package/dist/generated/sqlite/browser.d.ts.map +1 -1
  8. package/dist/generated/sqlite/client.d.ts +10 -0
  9. package/dist/generated/sqlite/client.d.ts.map +1 -1
  10. package/dist/generated/sqlite/internal/class.d.ts +22 -0
  11. package/dist/generated/sqlite/internal/class.d.ts.map +1 -1
  12. package/dist/generated/sqlite/internal/class.js +5 -5
  13. package/dist/generated/sqlite/internal/class.js.map +1 -1
  14. package/dist/generated/sqlite/internal/prismaNamespace.d.ts +180 -1
  15. package/dist/generated/sqlite/internal/prismaNamespace.d.ts.map +1 -1
  16. package/dist/generated/sqlite/internal/prismaNamespace.js +27 -0
  17. package/dist/generated/sqlite/internal/prismaNamespace.js.map +1 -1
  18. package/dist/generated/sqlite/internal/prismaNamespaceBrowser.d.ts +29 -0
  19. package/dist/generated/sqlite/internal/prismaNamespaceBrowser.d.ts.map +1 -1
  20. package/dist/generated/sqlite/internal/prismaNamespaceBrowser.js +27 -0
  21. package/dist/generated/sqlite/internal/prismaNamespaceBrowser.js.map +1 -1
  22. package/dist/generated/sqlite/models/Mod.d.ts +219 -0
  23. package/dist/generated/sqlite/models/Mod.d.ts.map +1 -1
  24. package/dist/generated/sqlite/models/PackFile.d.ts +1603 -0
  25. package/dist/generated/sqlite/models/PackFile.d.ts.map +1 -0
  26. package/dist/generated/sqlite/models/PackFile.js +2 -0
  27. package/dist/generated/sqlite/models/PackFile.js.map +1 -0
  28. package/dist/generated/sqlite/models/PackVersion.d.ts +1365 -0
  29. package/dist/generated/sqlite/models/PackVersion.d.ts.map +1 -0
  30. package/dist/generated/sqlite/models/PackVersion.js +2 -0
  31. package/dist/generated/sqlite/models/PackVersion.js.map +1 -0
  32. package/dist/generated/sqlite/models.d.ts +2 -0
  33. package/dist/generated/sqlite/models.d.ts.map +1 -1
  34. package/dist/java-tools.d.ts.map +1 -1
  35. package/dist/java-tools.js +75 -1
  36. package/dist/java-tools.js.map +1 -1
  37. package/dist/modpacks-ch.d.ts +5 -0
  38. package/dist/modpacks-ch.d.ts.map +1 -1
  39. package/dist/modpacks-ch.js +27 -5
  40. package/dist/modpacks-ch.js.map +1 -1
  41. package/dist/modrinth.d.ts +10 -0
  42. package/dist/modrinth.d.ts.map +1 -1
  43. package/dist/modrinth.js +20 -0
  44. package/dist/modrinth.js.map +1 -1
  45. package/dist/repositories/mod.d.ts.map +1 -1
  46. package/dist/repositories/mod.js +8 -1
  47. package/dist/repositories/mod.js.map +1 -1
  48. package/dist/server.js +43 -10
  49. package/dist/server.js.map +1 -1
  50. package/dist/tools/diagnostics.d.ts +2 -1
  51. package/dist/tools/diagnostics.d.ts.map +1 -1
  52. package/dist/tools/diagnostics.js +387 -56
  53. package/dist/tools/diagnostics.js.map +1 -1
  54. package/dist/tools/ingest.d.ts.map +1 -1
  55. package/dist/tools/ingest.js +3 -2
  56. package/dist/tools/ingest.js.map +1 -1
  57. package/dist/tools/modpacks-ch.d.ts +269 -3
  58. package/dist/tools/modpacks-ch.d.ts.map +1 -1
  59. package/dist/tools/modpacks-ch.js +810 -11
  60. package/dist/tools/modpacks-ch.js.map +1 -1
  61. package/dist/tools/source.d.ts.map +1 -1
  62. package/dist/tools/source.js +9 -1
  63. package/dist/tools/source.js.map +1 -1
  64. package/package.json +1 -1
  65. package/prisma/backends/schema.sqlite.prisma +42 -0
  66. package/prisma/backends/template.db +0 -0
@@ -19,20 +19,56 @@
19
19
  * search_ftb_mods — search the FTB mod index (returns mixed CF int / MR
20
20
  * string IDs)
21
21
  */
22
- import { join, resolve, sep } from "path";
22
+ import { basename, extname, join, resolve, sep } from "path";
23
23
  import { createHash } from "crypto";
24
- import { rename, mkdir } from "fs/promises";
24
+ import { rename, mkdir, unlink } from "fs/promises";
25
25
  import AdmZip from "adm-zip";
26
26
  import { ensureDir, exists, CACHE_ROOT } from "../cache.js";
27
27
  import { searchPacks, getFeaturedPacks, getPack, getPackManifest, getCfPack, getCfPackManifest, searchMods, getMod, getModsBatch, resolveModVersionUrl, USER_AGENT, downloadManifestFile, resolveFileUrl, } from "../modpacks-ch.js";
28
28
  import { fetchWithRetry, DOWNLOAD_OPTS } from "../fetch-utils.js";
29
29
  import { createWriteStream } from "fs";
30
30
  import { pipeline } from "stream/promises";
31
+ import { searchProjects as searchModrinthProjects, getProject as getModrinthProject, getProjectVersions as getModrinthProjectVersions, getProjectVersion as getModrinthProjectVersion, getVersion as getModrinthVersion, getPrimaryFile as getModrinthPrimaryFile, } from "../modrinth.js";
32
+ import { searchOfficialFtbPacks, getOfficialFtbPack, getOfficialFtbPackManifest, } from "../feed-the-beast.js";
31
33
  const MOD_HEADERS = { "User-Agent": USER_AGENT };
32
34
  import { ingestMod } from "./ingest.js";
33
35
  import { upsertPackVersion, upsertPackFile, listPackVersions, listPackFiles, findPacksForMod, findPacksForCfProject, findPackVersion, } from "../repositories/packs.js";
34
36
  // ── Pack search / browse ──────────────────────────────────────────────────────
35
37
  export async function searchPacksAction(term, namespace = "ftb", limit = 20) {
38
+ if (namespace === "modrinth") {
39
+ const r = await searchModrinthProjects(term, { projectType: "modpack", limit });
40
+ return {
41
+ modrinthPacks: (r?.hits ?? []).map((h) => ({
42
+ id: h.project_id,
43
+ slug: h.slug,
44
+ name: h.title,
45
+ description: h.description,
46
+ downloads: h.downloads,
47
+ versions: h.versions,
48
+ loaders: h.loaders,
49
+ url: `https://modrinth.com/modpack/${h.slug}`,
50
+ })),
51
+ total: r?.total_hits ?? 0,
52
+ };
53
+ }
54
+ if (namespace === "feedthebeast") {
55
+ const r = await searchOfficialFtbPacks(term, limit, true);
56
+ const packs = (r?.packs ?? []).filter((p) => typeof p === "object");
57
+ return {
58
+ feedTheBeastPacks: packs.map((p) => ({
59
+ id: p.id,
60
+ slug: p.slug,
61
+ name: p.name,
62
+ synopsis: p.synopsis,
63
+ provider: p.provider,
64
+ platform: p.platform,
65
+ updated: p.updated ? new Date(p.updated * 1000).toISOString() : null,
66
+ url: `https://www.feed-the-beast.com/modpacks/${p.id}`,
67
+ })),
68
+ total: r?.count ?? r?.total ?? packs.length,
69
+ note: "Official Feed The Beast API results. Use namespace=feedthebeast for info/list_versions/ingest_pack.",
70
+ };
71
+ }
36
72
  // Always call the unified FTB search endpoint — it returns both FTB pack IDs
37
73
  // (in `packs`) and CurseForge pack IDs (in `curseforge`) in a single response.
38
74
  // The /curseforge/search/ endpoint does not exist.
@@ -56,7 +92,503 @@ export async function featuredPacksAction(limit = 20) {
56
92
  return { packs: [], total: 0 };
57
93
  return r;
58
94
  }
95
+ function normalized(value) {
96
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
97
+ }
98
+ function hashToInt(value) {
99
+ return parseInt(createHash("sha1").update(value).digest("hex").slice(0, 7), 16);
100
+ }
101
+ function maybeNumber(value) {
102
+ if (typeof value === "number" && Number.isFinite(value))
103
+ return value;
104
+ if (typeof value === "string" && /^\d+$/.test(value))
105
+ return Number(value);
106
+ return undefined;
107
+ }
108
+ function parseModrinthWebRef(packRef, versionRef) {
109
+ if (typeof packRef !== "string")
110
+ return { packRef, versionRef };
111
+ let url;
112
+ try {
113
+ url = new URL(packRef);
114
+ }
115
+ catch {
116
+ return { packRef, versionRef };
117
+ }
118
+ if (url.protocol !== "https:") {
119
+ throw new Error(`Modrinth pack URL must use HTTPS: ${packRef}`);
120
+ }
121
+ const host = url.hostname.toLowerCase().replace(/^www\./, "");
122
+ const parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
123
+ if (url.pathname.toLowerCase().endsWith(".mrpack")) {
124
+ const cdnData = host === "cdn.modrinth.com" && parts[0] === "data" && parts[2] === "versions"
125
+ ? { packRef: parts[1], versionRef: versionRef ?? parts[3] }
126
+ : {};
127
+ return { ...cdnData, mrpackUrl: url.toString() };
128
+ }
129
+ if (host === "modrinth.com") {
130
+ const packIdx = parts.indexOf("modpack");
131
+ if (packIdx >= 0 && parts[packIdx + 1]) {
132
+ const parsedVersion = parts[packIdx + 2] === "version" ? parts[packIdx + 3] : undefined;
133
+ return { packRef: parts[packIdx + 1], versionRef: versionRef ?? parsedVersion };
134
+ }
135
+ }
136
+ if (host === "api.modrinth.com") {
137
+ const projectIdx = parts.indexOf("project");
138
+ if (projectIdx >= 0 && parts[projectIdx + 1]) {
139
+ const parsedVersion = parts[projectIdx + 2] === "version" ? parts[projectIdx + 3] : undefined;
140
+ return { packRef: parts[projectIdx + 1], versionRef: versionRef ?? parsedVersion };
141
+ }
142
+ const versionIdx = parts.indexOf("version");
143
+ if (versionIdx >= 0 && parts[versionIdx + 1]) {
144
+ return { versionRef: versionRef ?? parts[versionIdx + 1], versionIdOnly: parts[versionIdx + 1] };
145
+ }
146
+ }
147
+ return { packRef, versionRef };
148
+ }
149
+ function modrinthInternalPackId(projectId) {
150
+ return hashToInt(`modrinth:project:${projectId}`);
151
+ }
152
+ function modrinthInternalVersionId(versionId) {
153
+ return hashToInt(`modrinth:version:${versionId}`);
154
+ }
155
+ function pickModrinthLoader(deps, loaders) {
156
+ if (deps.neoforge)
157
+ return "neoforge";
158
+ if (deps.forge)
159
+ return "forge";
160
+ if (deps["fabric-loader"])
161
+ return "fabric";
162
+ if (deps["quilt-loader"])
163
+ return "quilt";
164
+ return loaders?.find((l) => l !== "minecraft") ?? null;
165
+ }
166
+ function classifyModrinthPath(path) {
167
+ const p = path.replace(/\\/g, "/").toLowerCase();
168
+ if (p.startsWith("mods/") && p.endsWith(".jar"))
169
+ return "mod";
170
+ if (p.startsWith("resourcepacks/"))
171
+ return "resource";
172
+ if (p.startsWith("datapacks/"))
173
+ return "resource";
174
+ if (p.startsWith("config/") || p.startsWith("defaultconfigs/"))
175
+ return "config";
176
+ if (p.startsWith("kubejs/") || p.startsWith("scripts/"))
177
+ return "script";
178
+ return "override";
179
+ }
180
+ function resolveVersionByRef(versions, ref) {
181
+ if (versions.length === 0)
182
+ throw new Error("Pack has no versions");
183
+ if (ref === undefined || ref === "")
184
+ return versions[0];
185
+ const numeric = maybeNumber(ref);
186
+ if (numeric !== undefined) {
187
+ const hit = versions.find((v) => typeof v.id === "number" && v.id === numeric);
188
+ if (hit)
189
+ return hit;
190
+ }
191
+ const wanted = normalized(String(ref));
192
+ const hit = versions.find((v) => normalized(String(v.id)) === wanted ||
193
+ normalized(v.name) === wanted ||
194
+ (v.version !== undefined && normalized(v.version) === wanted) ||
195
+ (v.version_number !== undefined && normalized(v.version_number) === wanted));
196
+ if (hit)
197
+ return hit;
198
+ const partialMatches = versions.filter((v) => normalized(v.name).includes(wanted) ||
199
+ (v.version !== undefined && normalized(v.version).includes(wanted)) ||
200
+ (v.version_number !== undefined && normalized(v.version_number).includes(wanted)));
201
+ if (partialMatches.length === 1)
202
+ return partialMatches[0];
203
+ if (partialMatches.length > 1) {
204
+ const examples = partialMatches.slice(0, 8).map((v) => `${v.name} (${String(v.id)})`).join(", ");
205
+ throw new Error(`Version "${String(ref)}" is ambiguous. Matching versions: ${examples}`);
206
+ }
207
+ {
208
+ const examples = versions.slice(0, 8).map((v) => `${v.name} (${String(v.id)})`).join(", ");
209
+ throw new Error(`Version "${String(ref)}" not found. Available examples: ${examples}`);
210
+ }
211
+ }
212
+ function versionMatchesRef(version, ref) {
213
+ if (ref === undefined || ref === "")
214
+ return false;
215
+ const wanted = normalized(String(ref));
216
+ return normalized(String(version.id)) === wanted ||
217
+ normalized(version.name) === wanted ||
218
+ normalized(version.name).includes(wanted) ||
219
+ (version.version !== undefined && normalized(version.version).includes(wanted)) ||
220
+ (version.version_number !== undefined && normalized(version.version_number).includes(wanted));
221
+ }
222
+ async function resolveModpacksChPack(namespace, rawPackRef) {
223
+ let packId = maybeNumber(rawPackRef);
224
+ if (packId === undefined) {
225
+ if (rawPackRef === undefined)
226
+ throw new Error("Provide packId, packRef, or a pack name");
227
+ const search = await searchPacks(String(rawPackRef), 20);
228
+ const ids = namespace === "curseforge" ? (search?.curseforge ?? []) : (search?.packs ?? []);
229
+ if (ids.length === 0)
230
+ throw new Error(`No ${namespace} pack found for "${String(rawPackRef)}"`);
231
+ const packs = (await Promise.all(ids.slice(0, 10).map((id) => (namespace === "curseforge" ? getCfPack(id) : getPack(id)).catch(() => null)))).filter((p) => p !== null);
232
+ const wanted = normalized(String(rawPackRef));
233
+ const scored = packs
234
+ .map((pack) => {
235
+ const name = normalized(pack.name);
236
+ const score = name === wanted ? 0 : name.includes(wanted) ? 1 : wanted.includes(name) ? 2 : 3;
237
+ return { pack, score };
238
+ })
239
+ .sort((a, b) => a.score - b.score || b.pack.installs - a.pack.installs);
240
+ packId = scored[0]?.pack.id ?? ids[0];
241
+ }
242
+ const pack = namespace === "curseforge" ? await getCfPack(packId) : await getPack(packId);
243
+ if (!pack)
244
+ throw new Error(`Pack ${packId} not found on namespace "${namespace}"`);
245
+ return pack;
246
+ }
247
+ function normalizeOfficialFtbManifest(manifest) {
248
+ return {
249
+ id: manifest.id,
250
+ parent: manifest.parent,
251
+ name: manifest.name,
252
+ type: manifest.type,
253
+ version: manifest.version ?? manifest.name,
254
+ targets: manifest.targets ?? [],
255
+ specs: manifest.specs,
256
+ installs: manifest.installs ?? 0,
257
+ plays: manifest.plays ?? 0,
258
+ updated: manifest.updated ?? 0,
259
+ refreshed: manifest.refreshed ?? 0,
260
+ status: manifest.status ?? "success",
261
+ files: (manifest.files ?? []).map((f) => {
262
+ const cfProject = Number(f.curseforge?.project);
263
+ const cfFile = Number(f.curseforge?.file);
264
+ return {
265
+ id: f.id,
266
+ name: f.name,
267
+ type: f.type,
268
+ path: f.path,
269
+ url: f.url,
270
+ mirror: f.mirror ?? f.mirrors?.[0] ?? "",
271
+ sha1: f.sha1 ?? f.hashes?.sha1 ?? "",
272
+ size: f.size,
273
+ clientonly: f.clientonly,
274
+ serveronly: f.serveronly,
275
+ optional: f.optional,
276
+ tags: f.tags ?? [],
277
+ curseforge: Number.isFinite(cfProject) && Number.isFinite(cfFile)
278
+ ? { project: cfProject, file: cfFile }
279
+ : undefined,
280
+ };
281
+ }),
282
+ };
283
+ }
284
+ async function resolveOfficialFtbPack(rawPackRef) {
285
+ let packId = maybeNumber(rawPackRef);
286
+ if (packId === undefined) {
287
+ if (rawPackRef === undefined)
288
+ throw new Error("Provide packId, packRef, or a pack name");
289
+ const search = await searchOfficialFtbPacks(String(rawPackRef), 20, true);
290
+ const packs = (search?.packs ?? []).filter((p) => typeof p === "object");
291
+ if (packs.length === 0)
292
+ throw new Error(`No official Feed The Beast pack found for "${String(rawPackRef)}"`);
293
+ const wanted = normalized(String(rawPackRef));
294
+ const scored = packs
295
+ .map((pack) => {
296
+ const name = normalized(pack.name);
297
+ const slug = normalized(pack.slug ?? "");
298
+ const score = name === wanted || slug === wanted ? 0 :
299
+ name.includes(wanted) || slug.includes(wanted) ? 1 :
300
+ wanted.includes(name) ? 2 : 3;
301
+ return { pack, score };
302
+ })
303
+ .sort((a, b) => a.score - b.score || (b.pack.installs ?? 0) - (a.pack.installs ?? 0));
304
+ packId = scored[0]?.pack.id;
305
+ }
306
+ if (packId === undefined)
307
+ throw new Error(`No official Feed The Beast pack found for "${String(rawPackRef)}"`);
308
+ const pack = await getOfficialFtbPack(packId);
309
+ if (!pack)
310
+ throw new Error(`Official Feed The Beast pack ${packId} not found`);
311
+ return pack;
312
+ }
313
+ async function resolveOfficialFtbPackVersion(opts) {
314
+ const rawPackRef = opts.packId ?? opts.packRef;
315
+ const rawVersionRef = opts.versionId ?? opts.versionRef;
316
+ const pack = await resolveOfficialFtbPack(rawPackRef);
317
+ const version = resolveVersionByRef(pack.versions ?? [], rawVersionRef);
318
+ return {
319
+ namespace: "feedthebeast",
320
+ packId: pack.id,
321
+ versionId: version.id,
322
+ packName: pack.name,
323
+ versionName: version.name,
324
+ mcVersion: version.targets.find((t) => t.type === "game" || t.name === "minecraft")?.version ?? null,
325
+ loader: version.targets.find((t) => t.type === "modloader")?.name ?? null,
326
+ sourcePackId: pack.id,
327
+ sourceVersionId: version.id,
328
+ };
329
+ }
330
+ async function resolveModpacksChPackVersion(opts) {
331
+ const namespace = opts.namespace === "curseforge" ? "curseforge" : "ftb";
332
+ const rawPackRef = opts.packId ?? opts.packRef;
333
+ const rawVersionRef = opts.versionId ?? opts.versionRef;
334
+ const pack = await resolveModpacksChPack(namespace, rawPackRef);
335
+ const version = resolveVersionByRef(pack.versions ?? [], rawVersionRef);
336
+ return {
337
+ namespace,
338
+ packId: pack.id,
339
+ versionId: version.id,
340
+ packName: pack.name,
341
+ versionName: version.name,
342
+ mcVersion: version.targets.find((t) => t.type === "game" || t.name === "minecraft")?.version ?? null,
343
+ loader: version.targets.find((t) => t.type === "modloader")?.name ?? null,
344
+ sourcePackId: pack.id,
345
+ sourceVersionId: version.id,
346
+ };
347
+ }
348
+ export async function listRemotePackVersionsAction(opts) {
349
+ const namespace = opts.namespace ?? "ftb";
350
+ const versionRef = opts.versionId ?? opts.versionRef;
351
+ if (namespace === "modrinth") {
352
+ const parsed = parseModrinthWebRef(opts.packId ?? opts.packRef, versionRef);
353
+ if (parsed.mrpackUrl) {
354
+ const { index } = await downloadModrinthPackIndexFromUrl(parsed.mrpackUrl);
355
+ const row = {
356
+ id: parsed.versionRef ?? index.versionId,
357
+ internalVersionId: modrinthInternalVersionId(String(parsed.versionRef ?? index.versionId)),
358
+ name: index.versionId,
359
+ version: index.versionId,
360
+ type: "release",
361
+ mcVersions: index.dependencies.minecraft ? [index.dependencies.minecraft] : [],
362
+ loaders: [pickModrinthLoader(index.dependencies) ?? "unknown"].filter((v) => v !== "unknown"),
363
+ updated: null,
364
+ matchesRef: versionRef === undefined || versionMatchesRef({ id: parsed.versionRef ?? index.versionId, name: index.versionId, version_number: index.versionId }, versionRef),
365
+ };
366
+ return {
367
+ namespace,
368
+ packId: modrinthInternalPackId(String(parsed.packRef ?? parsed.mrpackUrl)),
369
+ sourcePackId: parsed.packRef ?? parsed.mrpackUrl,
370
+ packName: index.name,
371
+ total: 1,
372
+ matches: versionRef === undefined ? undefined : (row.matchesRef ? [row] : []),
373
+ versions: [row],
374
+ };
375
+ }
376
+ if (parsed.versionIdOnly) {
377
+ const version = await getModrinthVersion(parsed.versionIdOnly);
378
+ if (!version)
379
+ throw new Error(`Modrinth version ${parsed.versionIdOnly} not found`);
380
+ parsed.packRef = version.project_id;
381
+ parsed.versionRef = parsed.versionRef ?? version.id;
382
+ }
383
+ const project = await resolveModrinthPack(parsed.packRef);
384
+ const versions = await getModrinthProjectVersions(project.slug || project.id);
385
+ const rows = versions.map((v) => ({
386
+ id: v.id,
387
+ internalVersionId: modrinthInternalVersionId(v.id),
388
+ name: v.name,
389
+ version: v.version_number,
390
+ type: v.version_type,
391
+ mcVersions: v.game_versions ?? [],
392
+ loaders: v.loaders ?? [],
393
+ updated: v.date_published,
394
+ matchesRef: versionMatchesRef(v, versionRef),
395
+ }));
396
+ return {
397
+ namespace,
398
+ packId: modrinthInternalPackId(project.id),
399
+ sourcePackId: project.id,
400
+ slug: project.slug,
401
+ packName: project.title,
402
+ total: rows.length,
403
+ matches: versionRef === undefined ? undefined : rows.filter((v) => v.matchesRef),
404
+ versions: rows,
405
+ };
406
+ }
407
+ if (namespace === "feedthebeast") {
408
+ const pack = await resolveOfficialFtbPack(opts.packId ?? opts.packRef);
409
+ const rows = (pack.versions ?? []).map((v) => ({
410
+ id: v.id,
411
+ name: v.name,
412
+ type: v.type,
413
+ mcVersions: (v.targets ?? []).filter((t) => t.type === "game" || t.name === "minecraft").map((t) => t.version),
414
+ loaders: (v.targets ?? []).filter((t) => t.type === "modloader").map((t) => t.name),
415
+ updated: v.updated ? new Date(v.updated * 1000).toISOString() : null,
416
+ matchesRef: versionMatchesRef(v, versionRef),
417
+ }));
418
+ return {
419
+ namespace,
420
+ packId: pack.id,
421
+ slug: pack.slug,
422
+ packName: pack.name,
423
+ total: rows.length,
424
+ matches: versionRef === undefined ? undefined : rows.filter((v) => v.matchesRef),
425
+ versions: rows,
426
+ };
427
+ }
428
+ const ns = namespace === "curseforge" ? "curseforge" : "ftb";
429
+ const pack = await resolveModpacksChPack(ns, opts.packId ?? opts.packRef);
430
+ const rows = (pack.versions ?? []).map((v) => ({
431
+ id: v.id,
432
+ name: v.name,
433
+ type: v.type,
434
+ mcVersions: (v.targets ?? []).filter((t) => t.type === "game" || t.name === "minecraft").map((t) => t.version),
435
+ loaders: (v.targets ?? []).filter((t) => t.type === "modloader").map((t) => t.name),
436
+ updated: new Date(v.updated * 1000).toISOString(),
437
+ matchesRef: versionMatchesRef(v, versionRef),
438
+ }));
439
+ return {
440
+ namespace: ns,
441
+ packId: pack.id,
442
+ packName: pack.name,
443
+ total: rows.length,
444
+ matches: versionRef === undefined ? undefined : rows.filter((v) => v.matchesRef),
445
+ versions: rows,
446
+ };
447
+ }
448
+ async function resolveModrinthPack(packRef) {
449
+ if (packRef === undefined)
450
+ throw new Error("Provide packId, packRef, or a pack name");
451
+ const parsed = parseModrinthWebRef(packRef);
452
+ if (parsed.versionIdOnly) {
453
+ const version = await getModrinthVersion(parsed.versionIdOnly);
454
+ if (!version)
455
+ throw new Error(`Modrinth version ${parsed.versionIdOnly} not found`);
456
+ return resolveModrinthPack(version.project_id);
457
+ }
458
+ if (parsed.packRef !== packRef)
459
+ return resolveModrinthPack(parsed.packRef);
460
+ const direct = await getModrinthProject(String(packRef)).catch(() => null);
461
+ if (direct?.project_type === "modpack" || (direct && direct.project_type === undefined))
462
+ return direct;
463
+ const search = await searchModrinthProjects(String(packRef), { projectType: "modpack", limit: 10 });
464
+ const hits = search?.hits ?? [];
465
+ if (hits.length === 0)
466
+ throw new Error(`No Modrinth modpack found for "${String(packRef)}"`);
467
+ const exact = hits.find((h) => normalized(h.slug) === normalized(String(packRef)) ||
468
+ normalized(h.title) === normalized(String(packRef)) ||
469
+ h.project_id === String(packRef));
470
+ const hit = exact ?? hits[0];
471
+ const project = await getModrinthProject(hit.project_id);
472
+ if (!project)
473
+ throw new Error(`Modrinth project ${hit.project_id} not found after search`);
474
+ return project;
475
+ }
476
+ async function resolveModrinthVersion(project, versionRef) {
477
+ const parsed = parseModrinthWebRef(project.slug || project.id, versionRef);
478
+ versionRef = parsed.versionRef;
479
+ if (versionRef !== undefined && versionRef !== "") {
480
+ const direct = await getModrinthProjectVersion(project.slug || project.id, String(versionRef)).catch(() => null);
481
+ if (direct)
482
+ return direct;
483
+ }
484
+ const versions = await getModrinthProjectVersions(project.slug || project.id);
485
+ return resolveVersionByRef(versions, versionRef);
486
+ }
487
+ export async function resolvePackAction(opts) {
488
+ const namespace = opts.namespace ?? "ftb";
489
+ if (namespace === "feedthebeast")
490
+ return resolveOfficialFtbPackVersion(opts);
491
+ if (namespace !== "modrinth")
492
+ return resolveModpacksChPackVersion(opts);
493
+ const parsed = parseModrinthWebRef(opts.packId ?? opts.packRef, opts.versionId ?? opts.versionRef);
494
+ if (parsed.mrpackUrl) {
495
+ const { index } = await downloadModrinthPackIndexFromUrl(parsed.mrpackUrl);
496
+ const sourcePackId = String(parsed.packRef ?? parsed.mrpackUrl);
497
+ const sourceVersionId = String(parsed.versionRef ?? index.versionId);
498
+ return {
499
+ namespace: "modrinth",
500
+ packId: modrinthInternalPackId(sourcePackId),
501
+ versionId: modrinthInternalVersionId(sourceVersionId),
502
+ packName: index.name,
503
+ versionName: index.versionId,
504
+ mcVersion: index.dependencies.minecraft ?? null,
505
+ loader: pickModrinthLoader(index.dependencies),
506
+ sourcePackId,
507
+ sourceVersionId,
508
+ };
509
+ }
510
+ if (parsed.versionIdOnly) {
511
+ const version = await getModrinthVersion(parsed.versionIdOnly);
512
+ if (!version)
513
+ throw new Error(`Modrinth version ${parsed.versionIdOnly} not found`);
514
+ const project = await resolveModrinthPack(version.project_id);
515
+ return {
516
+ namespace: "modrinth",
517
+ packId: modrinthInternalPackId(project.id),
518
+ versionId: modrinthInternalVersionId(version.id),
519
+ packName: project.title,
520
+ versionName: version.name || version.version_number,
521
+ mcVersion: version.game_versions?.[0] ?? null,
522
+ loader: version.loaders?.find((l) => l !== "minecraft") ?? null,
523
+ sourcePackId: project.id,
524
+ sourceVersionId: version.id,
525
+ };
526
+ }
527
+ const project = await resolveModrinthPack(parsed.packRef);
528
+ const version = await resolveModrinthVersion(project, parsed.versionRef);
529
+ return {
530
+ namespace: "modrinth",
531
+ packId: modrinthInternalPackId(project.id),
532
+ versionId: modrinthInternalVersionId(version.id),
533
+ packName: project.title,
534
+ versionName: version.name || version.version_number,
535
+ mcVersion: version.game_versions?.[0] ?? null,
536
+ loader: version.loaders?.find((l) => l !== "minecraft") ?? null,
537
+ sourcePackId: project.id,
538
+ sourceVersionId: version.id,
539
+ };
540
+ }
59
541
  export async function packInfoAction(packId, namespace = "ftb") {
542
+ if (namespace === "modrinth") {
543
+ const project = await getModrinthProject(String(packId));
544
+ if (!project)
545
+ throw new Error(`Modrinth pack ${packId} not found`);
546
+ const versions = await getModrinthProjectVersions(project.slug || project.id);
547
+ return {
548
+ id: project.id,
549
+ internalPackId: modrinthInternalPackId(project.id),
550
+ slug: project.slug,
551
+ name: project.title,
552
+ synopsis: project.description,
553
+ provider: "modrinth",
554
+ versions: versions.map((v) => ({
555
+ id: v.id,
556
+ internalVersionId: modrinthInternalVersionId(v.id),
557
+ name: v.name,
558
+ version: v.version_number,
559
+ type: v.version_type,
560
+ targets: [
561
+ ...(v.game_versions ?? []).map((version) => ({ name: "minecraft", version })),
562
+ ...(v.loaders ?? []).map((name) => ({ name, version: "" })),
563
+ ],
564
+ updated: v.date_published,
565
+ })),
566
+ };
567
+ }
568
+ if (namespace === "feedthebeast") {
569
+ const pack = await getOfficialFtbPack(packId);
570
+ if (!pack)
571
+ throw new Error(`Official Feed The Beast pack ${packId} not found`);
572
+ return {
573
+ id: pack.id,
574
+ slug: pack.slug,
575
+ name: pack.name,
576
+ synopsis: pack.synopsis,
577
+ provider: "feedthebeast",
578
+ installs: pack.installs,
579
+ plays: pack.plays,
580
+ tags: (pack.tags ?? []).map((t) => t.name),
581
+ authors: (pack.authors ?? []).map((a) => ({ name: a.name, type: a.type })),
582
+ links: (pack.links ?? []).map((l) => ({ type: l.type, url: l.link })),
583
+ versions: (pack.versions ?? []).map((v) => ({
584
+ id: v.id,
585
+ name: v.name,
586
+ type: v.type,
587
+ targets: (v.targets ?? []).map((t) => ({ name: t.name, version: t.version })),
588
+ updated: v.updated ? new Date(v.updated * 1000).toISOString() : null,
589
+ })),
590
+ };
591
+ }
60
592
  const pack = namespace === "curseforge"
61
593
  ? await getCfPack(packId)
62
594
  : await getPack(packId);
@@ -82,9 +614,14 @@ export async function packInfoAction(packId, namespace = "ftb") {
82
614
  };
83
615
  }
84
616
  export async function packManifestAction(packId, versionId, namespace = "ftb") {
85
- const manifest = namespace === "curseforge"
86
- ? await getCfPackManifest(packId, versionId)
87
- : await getPackManifest(packId, versionId);
617
+ if (namespace === "modrinth") {
618
+ throw new Error("Use action=ingest_pack or action=resolve_pack for Modrinth packs; Modrinth manifests are stored inside the .mrpack download.");
619
+ }
620
+ const manifest = namespace === "feedthebeast"
621
+ ? await getOfficialFtbPackManifest(packId, versionId).then((m) => m ? normalizeOfficialFtbManifest(m) : null)
622
+ : namespace === "curseforge"
623
+ ? await getCfPackManifest(packId, versionId)
624
+ : await getPackManifest(packId, versionId);
88
625
  if (!manifest)
89
626
  throw new Error(`Manifest not found for pack ${packId} version ${versionId} (${namespace})`);
90
627
  const fileSummary = (f) => ({
@@ -248,9 +785,11 @@ export async function syncPackModsAction(opts) {
248
785
  const ingestTypes = opts.fileTypes ?? ["mod", "resource"];
249
786
  const skipServer = opts.skipServer ?? false;
250
787
  const skipOptional = opts.skipOptional ?? false;
251
- const manifest = namespace === "curseforge"
252
- ? await getCfPackManifest(packId, versionId)
253
- : await getPackManifest(packId, versionId);
788
+ const manifest = namespace === "feedthebeast"
789
+ ? await getOfficialFtbPackManifest(packId, versionId).then((m) => m ? normalizeOfficialFtbManifest(m) : null)
790
+ : namespace === "curseforge"
791
+ ? await getCfPackManifest(packId, versionId)
792
+ : await getPackManifest(packId, versionId);
254
793
  if (!manifest) {
255
794
  throw new Error(`Manifest not found for pack ${packId} v${versionId} (${namespace})`);
256
795
  }
@@ -265,7 +804,7 @@ export async function syncPackModsAction(opts) {
265
804
  if (!resolveFileUrl(f))
266
805
  return false; // no URL → skip
267
806
  return true;
268
- });
807
+ }).slice(0, opts.maxFiles ?? undefined);
269
808
  const packCacheDir = join(CACHE_ROOT, "packs", String(namespace), String(packId), String(versionId));
270
809
  const results = [];
271
810
  // Process `concurrency` files at a time
@@ -282,8 +821,8 @@ export async function syncPackModsAction(opts) {
282
821
  namespace,
283
822
  packId,
284
823
  versionId,
285
- packName: manifest.name,
286
- versionName: manifest.version || manifest.name,
824
+ packName: opts.packName ?? manifest.name,
825
+ versionName: (opts.versionName ?? manifest.version) || manifest.name,
287
826
  mcVersion,
288
827
  modloader,
289
828
  });
@@ -369,6 +908,266 @@ async function processFile(file, cacheDir) {
369
908
  }
370
909
  // ── Pack membership queries ───────────────────────────────────────────────────
371
910
  /** List all pack versions recorded in the DB, optionally filtered by namespace / packId. */
911
+ async function downloadUrlToFile(url, destPath) {
912
+ const res = await fetchWithRetry(url, { headers: MOD_HEADERS }, DOWNLOAD_OPTS);
913
+ if (!res.ok)
914
+ throw new Error(`Download failed: HTTP ${res.status} from ${url}`);
915
+ const writer = createWriteStream(destPath);
916
+ await pipeline(res.body, writer);
917
+ }
918
+ async function processDownloadableFile(file, cacheDir) {
919
+ const key = file.sha1 || createHash("sha1").update(file.url).digest("hex");
920
+ const ext = extname(file.name).toLowerCase() || (file.type === "mod" ? ".jar" : "");
921
+ const destPath = join(cacheDir, `${key}${ext}`);
922
+ try {
923
+ await ensureDir(destPath);
924
+ if (!(await exists(destPath))) {
925
+ const tmpPath = destPath + ".tmp";
926
+ try {
927
+ await downloadUrlToFile(file.url, tmpPath);
928
+ await rename(tmpPath, destPath);
929
+ }
930
+ catch (err) {
931
+ await unlink(tmpPath).catch(() => { });
932
+ throw err;
933
+ }
934
+ }
935
+ if (ext !== ".jar") {
936
+ return { name: file.name, type: file.type, fileId: file.id, status: ext === ".zip" ? "downloaded_zip" : "downloaded" };
937
+ }
938
+ const result = await ingestMod(destPath, /* skipSource */ true);
939
+ return {
940
+ name: file.name,
941
+ type: file.type,
942
+ fileId: file.id,
943
+ status: result.status,
944
+ dbId: result.status === "ingested" || result.status === "replaced"
945
+ ? result.mod?.id
946
+ : result.status === "already_ingested" || result.status === "metadata_refreshed"
947
+ ? result.mod?.id
948
+ : result.existingDbId,
949
+ message: "message" in result ? result.message : undefined,
950
+ };
951
+ }
952
+ catch (err) {
953
+ return {
954
+ name: file.name,
955
+ type: file.type,
956
+ fileId: file.id,
957
+ status: "error",
958
+ message: err instanceof Error ? err.message : String(err),
959
+ };
960
+ }
961
+ }
962
+ async function downloadModrinthPackIndex(project, version) {
963
+ const file = getModrinthPrimaryFile(version);
964
+ if (!file)
965
+ throw new Error(`Modrinth version ${version.id} has no downloadable files`);
966
+ if (!file.filename.endsWith(".mrpack")) {
967
+ throw new Error(`Primary file for Modrinth version ${version.id} is not an .mrpack: ${file.filename}`);
968
+ }
969
+ const packDir = join(CACHE_ROOT, "packs", "modrinth", project.id, version.id);
970
+ const mrpackPath = join(packDir, file.filename);
971
+ await mkdir(packDir, { recursive: true });
972
+ if (!(await exists(mrpackPath))) {
973
+ const tmpPath = mrpackPath + ".tmp";
974
+ try {
975
+ await downloadUrlToFile(file.url, tmpPath);
976
+ await rename(tmpPath, mrpackPath);
977
+ }
978
+ catch (err) {
979
+ await unlink(tmpPath).catch(() => { });
980
+ throw err;
981
+ }
982
+ }
983
+ const index = readModrinthPackIndex(mrpackPath, file.filename);
984
+ return { index, mrpackPath };
985
+ }
986
+ function readModrinthPackIndex(mrpackPath, label) {
987
+ const zip = new AdmZip(mrpackPath);
988
+ const indexEntry = zip.getEntry("modrinth.index.json");
989
+ if (!indexEntry)
990
+ throw new Error(`Modrinth pack ${label} has no modrinth.index.json`);
991
+ return JSON.parse(indexEntry.getData().toString("utf8"));
992
+ }
993
+ async function downloadModrinthPackIndexFromUrl(mrpackUrl) {
994
+ const url = new URL(mrpackUrl);
995
+ if (url.protocol !== "https:")
996
+ throw new Error(`Modrinth .mrpack URL must use HTTPS: ${mrpackUrl}`);
997
+ if (!url.pathname.toLowerCase().endsWith(".mrpack"))
998
+ throw new Error(`Expected a .mrpack URL, got: ${mrpackUrl}`);
999
+ const urlKey = createHash("sha1").update(url.toString()).digest("hex");
1000
+ const filename = decodeURIComponent(url.pathname.split("/").pop() ?? "pack.mrpack");
1001
+ const packDir = join(CACHE_ROOT, "packs", "modrinth-url", urlKey);
1002
+ const mrpackPath = join(packDir, filename);
1003
+ await mkdir(packDir, { recursive: true });
1004
+ if (!(await exists(mrpackPath))) {
1005
+ const tmpPath = mrpackPath + ".tmp";
1006
+ try {
1007
+ await downloadUrlToFile(url.toString(), tmpPath);
1008
+ await rename(tmpPath, mrpackPath);
1009
+ }
1010
+ catch (err) {
1011
+ await unlink(tmpPath).catch(() => { });
1012
+ throw err;
1013
+ }
1014
+ }
1015
+ return { index: readModrinthPackIndex(mrpackPath, filename), mrpackPath };
1016
+ }
1017
+ async function syncModrinthIndex(opts, meta, index, mrpackPath) {
1018
+ const ingestTypes = opts.fileTypes ?? ["mod", "resource"];
1019
+ const skipServer = opts.skipServer ?? false;
1020
+ const skipOptional = opts.skipOptional ?? false;
1021
+ const concurrency = opts.concurrency ?? 3;
1022
+ const downloadable = index.files.map((f) => {
1023
+ const type = classifyModrinthPath(f.path);
1024
+ const url = f.downloads?.[0];
1025
+ if (!url)
1026
+ return null;
1027
+ return {
1028
+ id: hashToInt(`modrinth:file:${meta.sourceVersionId}:${f.path}`),
1029
+ name: basename(f.path),
1030
+ type,
1031
+ path: f.path,
1032
+ url,
1033
+ sha1: f.hashes?.sha1 ?? null,
1034
+ serveronly: f.env?.client === "unsupported" && f.env.server !== "unsupported",
1035
+ optional: f.env?.client === "optional" || f.env?.server === "optional",
1036
+ };
1037
+ }).filter((f) => f !== null);
1038
+ const candidates = downloadable.filter((f) => {
1039
+ if (!ingestTypes.includes(f.type))
1040
+ return false;
1041
+ if (skipServer && f.serveronly)
1042
+ return false;
1043
+ if (skipOptional && f.optional)
1044
+ return false;
1045
+ return true;
1046
+ }).slice(0, opts.maxFiles ?? undefined);
1047
+ const packCacheDir = join(CACHE_ROOT, "packs", "modrinth", meta.sourcePackId.replace(/[^a-zA-Z0-9._-]/g, "_"), meta.sourceVersionId.replace(/[^a-zA-Z0-9._-]/g, "_"), "files");
1048
+ const results = [];
1049
+ for (let i = 0; i < candidates.length; i += concurrency) {
1050
+ const batch = candidates.slice(i, i + concurrency);
1051
+ const batchResults = await Promise.all(batch.map((file) => processDownloadableFile(file, packCacheDir)));
1052
+ results.push(...batchResults);
1053
+ }
1054
+ const modloader = pickModrinthLoader(index.dependencies, meta.loaders);
1055
+ const pvId = await upsertPackVersion({
1056
+ namespace: "modrinth",
1057
+ packId: meta.packId,
1058
+ versionId: meta.versionId,
1059
+ packName: meta.packName,
1060
+ versionName: meta.versionName,
1061
+ mcVersion: index.dependencies.minecraft ?? meta.mcVersion ?? null,
1062
+ modloader,
1063
+ });
1064
+ const resultMap = new Map();
1065
+ for (const r of results) {
1066
+ if (r.fileId !== undefined)
1067
+ resultMap.set(r.fileId, r);
1068
+ }
1069
+ await Promise.all(downloadable.map((f) => {
1070
+ const processed = resultMap.get(f.id);
1071
+ return upsertPackFile({
1072
+ packVersionId: pvId,
1073
+ manifestFileId: f.id,
1074
+ fileType: f.type,
1075
+ fileName: f.name,
1076
+ filePath: f.path || null,
1077
+ sha1: f.sha1 || null,
1078
+ status: processed?.status ?? "not_synced",
1079
+ modId: processed?.dbId ?? null,
1080
+ });
1081
+ }));
1082
+ const ingested = results.filter((r) => r.status === "ingested" || r.status === "replaced" || r.status === "metadata_refreshed").length;
1083
+ const skipped = results.filter((r) => r.status === "already_ingested" || r.status === "duplicate_version" || r.status === "duplicate_hash").length;
1084
+ const failed = results.filter((r) => r.status === "error").length;
1085
+ return {
1086
+ packId: meta.packId,
1087
+ versionId: meta.versionId,
1088
+ namespace: "modrinth",
1089
+ packVersionDbId: pvId,
1090
+ sourcePackId: meta.sourcePackId,
1091
+ sourceVersionId: meta.sourceVersionId,
1092
+ mrpackPath,
1093
+ total: candidates.length,
1094
+ ingested,
1095
+ skipped,
1096
+ failed,
1097
+ files: results,
1098
+ };
1099
+ }
1100
+ export async function syncModrinthPackAction(opts) {
1101
+ const parsed = parseModrinthWebRef(opts.packId ?? opts.packRef, opts.versionId ?? opts.versionRef);
1102
+ if (parsed.mrpackUrl) {
1103
+ const { index, mrpackPath } = await downloadModrinthPackIndexFromUrl(parsed.mrpackUrl);
1104
+ const sourcePackId = String(parsed.packRef ?? parsed.mrpackUrl);
1105
+ const sourceVersionId = String(parsed.versionRef ?? index.versionId);
1106
+ return syncModrinthIndex(opts, {
1107
+ packId: modrinthInternalPackId(sourcePackId),
1108
+ versionId: modrinthInternalVersionId(sourceVersionId),
1109
+ sourcePackId,
1110
+ sourceVersionId,
1111
+ packName: index.name,
1112
+ versionName: index.versionId,
1113
+ mcVersion: index.dependencies.minecraft ?? null,
1114
+ }, index, mrpackPath);
1115
+ }
1116
+ if (parsed.versionIdOnly) {
1117
+ const version = await getModrinthVersion(parsed.versionIdOnly);
1118
+ if (!version)
1119
+ throw new Error(`Modrinth version ${parsed.versionIdOnly} not found`);
1120
+ const project = await resolveModrinthPack(version.project_id);
1121
+ const { index, mrpackPath } = await downloadModrinthPackIndex(project, version);
1122
+ return syncModrinthIndex(opts, {
1123
+ packId: modrinthInternalPackId(project.id),
1124
+ versionId: modrinthInternalVersionId(version.id),
1125
+ sourcePackId: project.id,
1126
+ sourceVersionId: version.id,
1127
+ packName: project.title,
1128
+ versionName: version.name || version.version_number || index.versionId,
1129
+ mcVersion: version.game_versions?.[0] ?? null,
1130
+ loaders: version.loaders,
1131
+ }, index, mrpackPath);
1132
+ }
1133
+ const project = await resolveModrinthPack(parsed.packRef);
1134
+ const version = await resolveModrinthVersion(project, parsed.versionRef);
1135
+ const { index, mrpackPath } = await downloadModrinthPackIndex(project, version);
1136
+ return syncModrinthIndex(opts, {
1137
+ packId: modrinthInternalPackId(project.id),
1138
+ versionId: modrinthInternalVersionId(version.id),
1139
+ sourcePackId: project.id,
1140
+ sourceVersionId: version.id,
1141
+ packName: project.title,
1142
+ versionName: version.name || version.version_number || index.versionId,
1143
+ mcVersion: version.game_versions?.[0] ?? null,
1144
+ loaders: version.loaders,
1145
+ }, index, mrpackPath);
1146
+ }
1147
+ export async function ingestPackAction(opts) {
1148
+ const namespace = opts.namespace ?? "ftb";
1149
+ if (namespace === "modrinth")
1150
+ return syncModrinthPackAction(opts);
1151
+ const resolved = namespace === "feedthebeast"
1152
+ ? await resolveOfficialFtbPackVersion(opts)
1153
+ : await resolveModpacksChPackVersion(opts);
1154
+ const result = await syncPackModsAction({
1155
+ packId: resolved.packId,
1156
+ versionId: resolved.versionId,
1157
+ namespace: resolved.namespace,
1158
+ packName: resolved.packName,
1159
+ versionName: resolved.versionName,
1160
+ fileTypes: opts.fileTypes,
1161
+ skipServer: opts.skipServer,
1162
+ skipOptional: opts.skipOptional,
1163
+ concurrency: opts.concurrency,
1164
+ maxFiles: opts.maxFiles,
1165
+ });
1166
+ return {
1167
+ ...result,
1168
+ resolved,
1169
+ };
1170
+ }
372
1171
  export async function listPackVersionsAction(namespace, packId) {
373
1172
  const rows = await listPackVersions(namespace, packId);
374
1173
  return {