@opys/curseforge 0.1.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/dist/index.cjs +110 -0
- package/dist/index.d.cts +71 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +71 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +109 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +36 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
let _opys_dev = require("@opys/dev");
|
|
3
|
+
let _opys_core = require("@opys/core");
|
|
4
|
+
|
|
5
|
+
//#region lib/template.ts
|
|
6
|
+
const CURSEFORGE_API = "https://api.curseforge.com/v1";
|
|
7
|
+
const FILES_BATCH_SIZE = 200;
|
|
8
|
+
/** Fallback CDN URL used when CurseForge omits `downloadUrl`. */
|
|
9
|
+
function forgeCdnUrl(fileId, fileName) {
|
|
10
|
+
return `https://edge.forgecdn.net/files/${Math.floor(fileId / 1e3)}/${fileId % 1e3}/${encodeURIComponent(fileName)}`;
|
|
11
|
+
}
|
|
12
|
+
function pickSha1(file) {
|
|
13
|
+
return file.hashes.find((h) => h.algo === 1)?.value;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Coerce a `CurseForgeFileRef` into a numeric file ID. Numbers pass through;
|
|
17
|
+
* strings must contain a `/files/<digits>` segment (the standard CurseForge
|
|
18
|
+
* file URL shape).
|
|
19
|
+
*/
|
|
20
|
+
function parseFileRef(ref) {
|
|
21
|
+
if (typeof ref === "number") return ref;
|
|
22
|
+
const match = ref.match(/\/files\/(\d+)/);
|
|
23
|
+
if (!match) throw new Error(`CurseForge file ref "${ref}" does not contain "/files/<id>" — expected a numeric ID or a CurseForge file URL.`);
|
|
24
|
+
return Number(match[1]);
|
|
25
|
+
}
|
|
26
|
+
async function fetchFiles(token, fileIds) {
|
|
27
|
+
const out = [];
|
|
28
|
+
for (let i = 0; i < fileIds.length; i += FILES_BATCH_SIZE) {
|
|
29
|
+
const batch = fileIds.slice(i, i + FILES_BATCH_SIZE);
|
|
30
|
+
const res = await (0, _opys_core.fetchWithRetry)(`${CURSEFORGE_API}/mods/files`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: {
|
|
33
|
+
"x-api-key": token,
|
|
34
|
+
accept: "application/json",
|
|
35
|
+
"content-type": "application/json"
|
|
36
|
+
},
|
|
37
|
+
body: JSON.stringify({ fileIds: batch })
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) throw new Error(`CurseForge API ${res.status}: ${res.statusText} (POST /mods/files)`);
|
|
40
|
+
const json = await res.json();
|
|
41
|
+
out.push(...json.data);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve CurseForge file refs into opys `Artifact`s sharing one install
|
|
47
|
+
* path. Call multiple times for different destinations (mods,
|
|
48
|
+
* resourcepacks, shaderpacks, …) and drop each result straight into your
|
|
49
|
+
* manifest's `artifacts` list.
|
|
50
|
+
*
|
|
51
|
+
* ```ts
|
|
52
|
+
* const mods = await resolveCurseforge(
|
|
53
|
+
* {
|
|
54
|
+
* path: (info) => '${game_directory}/mods/' + info.filename,
|
|
55
|
+
* token: process.env.CURSEFORGE_API_KEY,
|
|
56
|
+
* },
|
|
57
|
+
* [
|
|
58
|
+
* 6307712,
|
|
59
|
+
* 'https://www.curseforge.com/minecraft/mc-mods/botania/files/2283837',
|
|
60
|
+
* ],
|
|
61
|
+
* );
|
|
62
|
+
* // mods: Artifact[]
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
async function resolveCurseforge(options, files) {
|
|
66
|
+
const ids = files.map(parseFileRef);
|
|
67
|
+
const meta = await fetchFiles(options.token, ids);
|
|
68
|
+
const byId = new Map(meta.map((m) => [m.id, m]));
|
|
69
|
+
const artifacts = [];
|
|
70
|
+
for (const fileId of ids) {
|
|
71
|
+
const file = byId.get(fileId);
|
|
72
|
+
if (!file) throw new Error(`CurseForge API did not return metadata for file ${fileId}`);
|
|
73
|
+
const path = options.path({
|
|
74
|
+
filename: file.fileName,
|
|
75
|
+
fileId: file.id,
|
|
76
|
+
projectId: file.modId,
|
|
77
|
+
size: file.fileLength
|
|
78
|
+
});
|
|
79
|
+
const url = file.downloadUrl ?? forgeCdnUrl(file.id, file.fileName);
|
|
80
|
+
const sha1 = pickSha1(file);
|
|
81
|
+
const integrity = sha1 ? { sha1 } : void 0;
|
|
82
|
+
artifacts.push({
|
|
83
|
+
path,
|
|
84
|
+
source: (0, _opys_core.sourceUrl)(url),
|
|
85
|
+
size: file.fileLength,
|
|
86
|
+
rules: [],
|
|
87
|
+
integrity
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return artifacts;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region lib/plugin.ts
|
|
95
|
+
/** Mod files resolved from the CurseForge API. */
|
|
96
|
+
function curseforge(options) {
|
|
97
|
+
return (0, _opys_dev.definePlugin)({
|
|
98
|
+
name: "curseforge",
|
|
99
|
+
async build(ctx) {
|
|
100
|
+
const { files, ...rest } = options;
|
|
101
|
+
const artifacts = await resolveCurseforge(rest, files);
|
|
102
|
+
ctx.log("curseforge", `${artifacts.length} file(s)`);
|
|
103
|
+
return { artifacts };
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
//#endregion
|
|
109
|
+
exports.curseforge = curseforge;
|
|
110
|
+
exports.resolveCurseforge = resolveCurseforge;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { OpysPlugin } from "@opys/dev";
|
|
2
|
+
import { Artifact } from "@opys/core";
|
|
3
|
+
|
|
4
|
+
//#region lib/template.d.ts
|
|
5
|
+
/** Info passed to the `path` callback. */
|
|
6
|
+
interface CurseForgeFileInfo {
|
|
7
|
+
/** Original filename as published on CurseForge, e.g. `jei-1.20.1-forge-15.21.1.5.jar`. */
|
|
8
|
+
filename: string;
|
|
9
|
+
/** CurseForge file ID. */
|
|
10
|
+
fileId: number;
|
|
11
|
+
/** CurseForge project (mod) ID. */
|
|
12
|
+
projectId: number;
|
|
13
|
+
/** File size in bytes. */
|
|
14
|
+
size: number;
|
|
15
|
+
}
|
|
16
|
+
type CurseForgePath = (info: CurseForgeFileInfo) => string;
|
|
17
|
+
/**
|
|
18
|
+
* A CurseForge file reference. Either a numeric file ID, or the file's
|
|
19
|
+
* CurseForge URL (`https://www.curseforge.com/<...>/files/<id>`) — the
|
|
20
|
+
* trailing numeric segment is parsed out so configs can paste links
|
|
21
|
+
* verbatim.
|
|
22
|
+
*/
|
|
23
|
+
type CurseForgeFileRef = number | string;
|
|
24
|
+
interface CurseForgeOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Install path callback, invoked once per file. May return a string
|
|
27
|
+
* containing opys install-time vars like `${root}` or
|
|
28
|
+
* `${game_directory}` — they get interpolated at install time.
|
|
29
|
+
*/
|
|
30
|
+
path: CurseForgePath;
|
|
31
|
+
/**
|
|
32
|
+
* CurseForge API key (https://console.curseforge.com/#/api-keys).
|
|
33
|
+
* Consumed only at build time — the artifact URLs are public CDN
|
|
34
|
+
* links, so end users running `opys launch` against a built manifest
|
|
35
|
+
* do not need a key.
|
|
36
|
+
*/
|
|
37
|
+
token: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolve CurseForge file refs into opys `Artifact`s sharing one install
|
|
41
|
+
* path. Call multiple times for different destinations (mods,
|
|
42
|
+
* resourcepacks, shaderpacks, …) and drop each result straight into your
|
|
43
|
+
* manifest's `artifacts` list.
|
|
44
|
+
*
|
|
45
|
+
* ```ts
|
|
46
|
+
* const mods = await resolveCurseforge(
|
|
47
|
+
* {
|
|
48
|
+
* path: (info) => '${game_directory}/mods/' + info.filename,
|
|
49
|
+
* token: process.env.CURSEFORGE_API_KEY,
|
|
50
|
+
* },
|
|
51
|
+
* [
|
|
52
|
+
* 6307712,
|
|
53
|
+
* 'https://www.curseforge.com/minecraft/mc-mods/botania/files/2283837',
|
|
54
|
+
* ],
|
|
55
|
+
* );
|
|
56
|
+
* // mods: Artifact[]
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
declare function resolveCurseforge(options: CurseForgeOptions, files: CurseForgeFileRef[]): Promise<Artifact[]>;
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region lib/plugin.d.ts
|
|
62
|
+
/** Options for the {@link curseforge} plugin. */
|
|
63
|
+
interface CurseforgePluginOptions extends CurseForgeOptions {
|
|
64
|
+
/** CurseForge file references — numeric IDs or `/files/<id>` URLs. */
|
|
65
|
+
files: CurseForgeFileRef[];
|
|
66
|
+
}
|
|
67
|
+
/** Mod files resolved from the CurseForge API. */
|
|
68
|
+
declare function curseforge(options: CurseforgePluginOptions): OpysPlugin;
|
|
69
|
+
//#endregion
|
|
70
|
+
export { type CurseForgeFileInfo, type CurseForgeFileRef, type CurseForgeOptions, type CurseForgePath, type CurseforgePluginOptions, curseforge, resolveCurseforge };
|
|
71
|
+
//# sourceMappingURL=index.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.cts","names":[],"sources":["../lib/template.ts","../lib/plugin.ts"],"mappings":";;;;;UAiBiB,kBAAA;;EAEf,QAAA;EAFiC;EAIjC,MAAA;EAJiC;EAMjC,SAAA;EAFA;EAIA,IAAA;AAAA;AAAA,KAGU,cAAA,IAAkB,IAAA,EAAM,kBAAA;;AAApC;;;;;KAQY,iBAAA;AAAA,UAEK,iBAAA;;;;AAAjB;;EAME,IAAA,EAAM,cAAA;EAAc;;;;;;EAOpB,KAAA;AAAA;;;;;;;;;;;;;;;;;;;AC3CF;;iBDqHsB,iBAAA,CACpB,OAAA,EAAS,iBAAA,EACT,KAAA,EAAO,iBAAA,KACN,OAAA,CAAQ,QAAA;;;;UCxHM,uBAAA,SAAgC,iBAAA;EDShC;ECPf,KAAA,EAAO,iBAAA;AAAA;;iBAIO,UAAA,CAAW,OAAA,EAAS,uBAAA,GAA0B,UAAA"}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { OpysPlugin } from "@opys/dev";
|
|
2
|
+
import { Artifact } from "@opys/core";
|
|
3
|
+
|
|
4
|
+
//#region lib/template.d.ts
|
|
5
|
+
/** Info passed to the `path` callback. */
|
|
6
|
+
interface CurseForgeFileInfo {
|
|
7
|
+
/** Original filename as published on CurseForge, e.g. `jei-1.20.1-forge-15.21.1.5.jar`. */
|
|
8
|
+
filename: string;
|
|
9
|
+
/** CurseForge file ID. */
|
|
10
|
+
fileId: number;
|
|
11
|
+
/** CurseForge project (mod) ID. */
|
|
12
|
+
projectId: number;
|
|
13
|
+
/** File size in bytes. */
|
|
14
|
+
size: number;
|
|
15
|
+
}
|
|
16
|
+
type CurseForgePath = (info: CurseForgeFileInfo) => string;
|
|
17
|
+
/**
|
|
18
|
+
* A CurseForge file reference. Either a numeric file ID, or the file's
|
|
19
|
+
* CurseForge URL (`https://www.curseforge.com/<...>/files/<id>`) — the
|
|
20
|
+
* trailing numeric segment is parsed out so configs can paste links
|
|
21
|
+
* verbatim.
|
|
22
|
+
*/
|
|
23
|
+
type CurseForgeFileRef = number | string;
|
|
24
|
+
interface CurseForgeOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Install path callback, invoked once per file. May return a string
|
|
27
|
+
* containing opys install-time vars like `${root}` or
|
|
28
|
+
* `${game_directory}` — they get interpolated at install time.
|
|
29
|
+
*/
|
|
30
|
+
path: CurseForgePath;
|
|
31
|
+
/**
|
|
32
|
+
* CurseForge API key (https://console.curseforge.com/#/api-keys).
|
|
33
|
+
* Consumed only at build time — the artifact URLs are public CDN
|
|
34
|
+
* links, so end users running `opys launch` against a built manifest
|
|
35
|
+
* do not need a key.
|
|
36
|
+
*/
|
|
37
|
+
token: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolve CurseForge file refs into opys `Artifact`s sharing one install
|
|
41
|
+
* path. Call multiple times for different destinations (mods,
|
|
42
|
+
* resourcepacks, shaderpacks, …) and drop each result straight into your
|
|
43
|
+
* manifest's `artifacts` list.
|
|
44
|
+
*
|
|
45
|
+
* ```ts
|
|
46
|
+
* const mods = await resolveCurseforge(
|
|
47
|
+
* {
|
|
48
|
+
* path: (info) => '${game_directory}/mods/' + info.filename,
|
|
49
|
+
* token: process.env.CURSEFORGE_API_KEY,
|
|
50
|
+
* },
|
|
51
|
+
* [
|
|
52
|
+
* 6307712,
|
|
53
|
+
* 'https://www.curseforge.com/minecraft/mc-mods/botania/files/2283837',
|
|
54
|
+
* ],
|
|
55
|
+
* );
|
|
56
|
+
* // mods: Artifact[]
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
declare function resolveCurseforge(options: CurseForgeOptions, files: CurseForgeFileRef[]): Promise<Artifact[]>;
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region lib/plugin.d.ts
|
|
62
|
+
/** Options for the {@link curseforge} plugin. */
|
|
63
|
+
interface CurseforgePluginOptions extends CurseForgeOptions {
|
|
64
|
+
/** CurseForge file references — numeric IDs or `/files/<id>` URLs. */
|
|
65
|
+
files: CurseForgeFileRef[];
|
|
66
|
+
}
|
|
67
|
+
/** Mod files resolved from the CurseForge API. */
|
|
68
|
+
declare function curseforge(options: CurseforgePluginOptions): OpysPlugin;
|
|
69
|
+
//#endregion
|
|
70
|
+
export { type CurseForgeFileInfo, type CurseForgeFileRef, type CurseForgeOptions, type CurseForgePath, type CurseforgePluginOptions, curseforge, resolveCurseforge };
|
|
71
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../lib/template.ts","../lib/plugin.ts"],"mappings":";;;;;UAiBiB,kBAAA;;EAEf,QAAA;EAFiC;EAIjC,MAAA;EAJiC;EAMjC,SAAA;EAFA;EAIA,IAAA;AAAA;AAAA,KAGU,cAAA,IAAkB,IAAA,EAAM,kBAAA;;AAApC;;;;;KAQY,iBAAA;AAAA,UAEK,iBAAA;;;;AAAjB;;EAME,IAAA,EAAM,cAAA;EAAc;;;;;;EAOpB,KAAA;AAAA;;;;;;;;;;;;;;;;;;;AC3CF;;iBDqHsB,iBAAA,CACpB,OAAA,EAAS,iBAAA,EACT,KAAA,EAAO,iBAAA,KACN,OAAA,CAAQ,QAAA;;;;UCxHM,uBAAA,SAAgC,iBAAA;EDShC;ECPf,KAAA,EAAO,iBAAA;AAAA;;iBAIO,UAAA,CAAW,OAAA,EAAS,uBAAA,GAA0B,UAAA"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { definePlugin } from "@opys/dev";
|
|
2
|
+
import { fetchWithRetry, sourceUrl } from "@opys/core";
|
|
3
|
+
|
|
4
|
+
//#region lib/template.ts
|
|
5
|
+
const CURSEFORGE_API = "https://api.curseforge.com/v1";
|
|
6
|
+
const FILES_BATCH_SIZE = 200;
|
|
7
|
+
/** Fallback CDN URL used when CurseForge omits `downloadUrl`. */
|
|
8
|
+
function forgeCdnUrl(fileId, fileName) {
|
|
9
|
+
return `https://edge.forgecdn.net/files/${Math.floor(fileId / 1e3)}/${fileId % 1e3}/${encodeURIComponent(fileName)}`;
|
|
10
|
+
}
|
|
11
|
+
function pickSha1(file) {
|
|
12
|
+
return file.hashes.find((h) => h.algo === 1)?.value;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Coerce a `CurseForgeFileRef` into a numeric file ID. Numbers pass through;
|
|
16
|
+
* strings must contain a `/files/<digits>` segment (the standard CurseForge
|
|
17
|
+
* file URL shape).
|
|
18
|
+
*/
|
|
19
|
+
function parseFileRef(ref) {
|
|
20
|
+
if (typeof ref === "number") return ref;
|
|
21
|
+
const match = ref.match(/\/files\/(\d+)/);
|
|
22
|
+
if (!match) throw new Error(`CurseForge file ref "${ref}" does not contain "/files/<id>" — expected a numeric ID or a CurseForge file URL.`);
|
|
23
|
+
return Number(match[1]);
|
|
24
|
+
}
|
|
25
|
+
async function fetchFiles(token, fileIds) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (let i = 0; i < fileIds.length; i += FILES_BATCH_SIZE) {
|
|
28
|
+
const batch = fileIds.slice(i, i + FILES_BATCH_SIZE);
|
|
29
|
+
const res = await fetchWithRetry(`${CURSEFORGE_API}/mods/files`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: {
|
|
32
|
+
"x-api-key": token,
|
|
33
|
+
accept: "application/json",
|
|
34
|
+
"content-type": "application/json"
|
|
35
|
+
},
|
|
36
|
+
body: JSON.stringify({ fileIds: batch })
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok) throw new Error(`CurseForge API ${res.status}: ${res.statusText} (POST /mods/files)`);
|
|
39
|
+
const json = await res.json();
|
|
40
|
+
out.push(...json.data);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve CurseForge file refs into opys `Artifact`s sharing one install
|
|
46
|
+
* path. Call multiple times for different destinations (mods,
|
|
47
|
+
* resourcepacks, shaderpacks, …) and drop each result straight into your
|
|
48
|
+
* manifest's `artifacts` list.
|
|
49
|
+
*
|
|
50
|
+
* ```ts
|
|
51
|
+
* const mods = await resolveCurseforge(
|
|
52
|
+
* {
|
|
53
|
+
* path: (info) => '${game_directory}/mods/' + info.filename,
|
|
54
|
+
* token: process.env.CURSEFORGE_API_KEY,
|
|
55
|
+
* },
|
|
56
|
+
* [
|
|
57
|
+
* 6307712,
|
|
58
|
+
* 'https://www.curseforge.com/minecraft/mc-mods/botania/files/2283837',
|
|
59
|
+
* ],
|
|
60
|
+
* );
|
|
61
|
+
* // mods: Artifact[]
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
async function resolveCurseforge(options, files) {
|
|
65
|
+
const ids = files.map(parseFileRef);
|
|
66
|
+
const meta = await fetchFiles(options.token, ids);
|
|
67
|
+
const byId = new Map(meta.map((m) => [m.id, m]));
|
|
68
|
+
const artifacts = [];
|
|
69
|
+
for (const fileId of ids) {
|
|
70
|
+
const file = byId.get(fileId);
|
|
71
|
+
if (!file) throw new Error(`CurseForge API did not return metadata for file ${fileId}`);
|
|
72
|
+
const path = options.path({
|
|
73
|
+
filename: file.fileName,
|
|
74
|
+
fileId: file.id,
|
|
75
|
+
projectId: file.modId,
|
|
76
|
+
size: file.fileLength
|
|
77
|
+
});
|
|
78
|
+
const url = file.downloadUrl ?? forgeCdnUrl(file.id, file.fileName);
|
|
79
|
+
const sha1 = pickSha1(file);
|
|
80
|
+
const integrity = sha1 ? { sha1 } : void 0;
|
|
81
|
+
artifacts.push({
|
|
82
|
+
path,
|
|
83
|
+
source: sourceUrl(url),
|
|
84
|
+
size: file.fileLength,
|
|
85
|
+
rules: [],
|
|
86
|
+
integrity
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return artifacts;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region lib/plugin.ts
|
|
94
|
+
/** Mod files resolved from the CurseForge API. */
|
|
95
|
+
function curseforge(options) {
|
|
96
|
+
return definePlugin({
|
|
97
|
+
name: "curseforge",
|
|
98
|
+
async build(ctx) {
|
|
99
|
+
const { files, ...rest } = options;
|
|
100
|
+
const artifacts = await resolveCurseforge(rest, files);
|
|
101
|
+
ctx.log("curseforge", `${artifacts.length} file(s)`);
|
|
102
|
+
return { artifacts };
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
//#endregion
|
|
108
|
+
export { curseforge, resolveCurseforge };
|
|
109
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../lib/template.ts","../lib/plugin.ts"],"sourcesContent":["import type { Artifact, HashEntry } from '@opys/core';\nimport { sourceUrl, fetchWithRetry } from '@opys/core';\n\nconst CURSEFORGE_API = 'https://api.curseforge.com/v1';\nconst FILES_BATCH_SIZE = 200;\n\n/** CurseForge file metadata returned by the v1 API. */\ninterface CFFile {\n id: number;\n modId: number;\n fileName: string;\n fileLength: number;\n hashes: { value: string; algo: number }[];\n downloadUrl: string | null;\n}\n\n/** Info passed to the `path` callback. */\nexport interface CurseForgeFileInfo {\n /** Original filename as published on CurseForge, e.g. `jei-1.20.1-forge-15.21.1.5.jar`. */\n filename: string;\n /** CurseForge file ID. */\n fileId: number;\n /** CurseForge project (mod) ID. */\n projectId: number;\n /** File size in bytes. */\n size: number;\n}\n\nexport type CurseForgePath = (info: CurseForgeFileInfo) => string;\n\n/**\n * A CurseForge file reference. Either a numeric file ID, or the file's\n * CurseForge URL (`https://www.curseforge.com/<...>/files/<id>`) — the\n * trailing numeric segment is parsed out so configs can paste links\n * verbatim.\n */\nexport type CurseForgeFileRef = number | string;\n\nexport interface CurseForgeOptions {\n /**\n * Install path callback, invoked once per file. May return a string\n * containing opys install-time vars like `${root}` or\n * `${game_directory}` — they get interpolated at install time.\n */\n path: CurseForgePath;\n /**\n * CurseForge API key (https://console.curseforge.com/#/api-keys).\n * Consumed only at build time — the artifact URLs are public CDN\n * links, so end users running `opys launch` against a built manifest\n * do not need a key.\n */\n token: string;\n}\n\n/** Fallback CDN URL used when CurseForge omits `downloadUrl`. */\nfunction forgeCdnUrl(fileId: number, fileName: string): string {\n const head = Math.floor(fileId / 1000);\n const tail = fileId % 1000;\n return `https://edge.forgecdn.net/files/${head}/${tail}/${encodeURIComponent(fileName)}`;\n}\n\nfunction pickSha1(file: CFFile): string | undefined {\n return file.hashes.find((h) => h.algo === 1)?.value;\n}\n\n/**\n * Coerce a `CurseForgeFileRef` into a numeric file ID. Numbers pass through;\n * strings must contain a `/files/<digits>` segment (the standard CurseForge\n * file URL shape).\n */\nfunction parseFileRef(ref: CurseForgeFileRef): number {\n if (typeof ref === 'number') return ref;\n const match = ref.match(/\\/files\\/(\\d+)/);\n if (!match) {\n throw new Error(\n `CurseForge file ref \"${ref}\" does not contain \"/files/<id>\" — expected a numeric ID or a CurseForge file URL.`,\n );\n }\n return Number(match[1]);\n}\n\nasync function fetchFiles(token: string, fileIds: number[]): Promise<CFFile[]> {\n const out: CFFile[] = [];\n for (let i = 0; i < fileIds.length; i += FILES_BATCH_SIZE) {\n const batch = fileIds.slice(i, i + FILES_BATCH_SIZE);\n const res = await fetchWithRetry(`${CURSEFORGE_API}/mods/files`, {\n method: 'POST',\n headers: {\n 'x-api-key': token,\n accept: 'application/json',\n 'content-type': 'application/json',\n },\n body: JSON.stringify({ fileIds: batch }),\n });\n if (!res.ok) {\n throw new Error(\n `CurseForge API ${res.status}: ${res.statusText} (POST /mods/files)`,\n );\n }\n const json = (await res.json()) as { data: CFFile[] };\n out.push(...json.data);\n }\n return out;\n}\n\n/**\n * Resolve CurseForge file refs into opys `Artifact`s sharing one install\n * path. Call multiple times for different destinations (mods,\n * resourcepacks, shaderpacks, …) and drop each result straight into your\n * manifest's `artifacts` list.\n *\n * ```ts\n * const mods = await resolveCurseforge(\n * {\n * path: (info) => '${game_directory}/mods/' + info.filename,\n * token: process.env.CURSEFORGE_API_KEY,\n * },\n * [\n * 6307712,\n * 'https://www.curseforge.com/minecraft/mc-mods/botania/files/2283837',\n * ],\n * );\n * // mods: Artifact[]\n * ```\n */\nexport async function resolveCurseforge(\n options: CurseForgeOptions,\n files: CurseForgeFileRef[],\n): Promise<Artifact[]> {\n const ids = files.map(parseFileRef);\n const meta = await fetchFiles(options.token, ids);\n const byId = new Map(meta.map((m) => [m.id, m]));\n\n const artifacts: Artifact[] = [];\n for (const fileId of ids) {\n const file = byId.get(fileId);\n if (!file) {\n throw new Error(\n `CurseForge API did not return metadata for file ${fileId}`,\n );\n }\n\n const path = options.path({\n filename: file.fileName,\n fileId: file.id,\n projectId: file.modId,\n size: file.fileLength,\n });\n\n const url = file.downloadUrl ?? forgeCdnUrl(file.id, file.fileName);\n const sha1 = pickSha1(file);\n const integrity: HashEntry | undefined = sha1 ? { sha1 } : undefined;\n\n artifacts.push({\n path,\n source: sourceUrl(url),\n size: file.fileLength,\n rules: [],\n integrity,\n });\n }\n\n return artifacts;\n}\n","import { definePlugin, type OpysPlugin } from '@opys/dev';\nimport {\n resolveCurseforge,\n type CurseForgeOptions,\n type CurseForgeFileRef,\n} from './template';\n\n/** Options for the {@link curseforge} plugin. */\nexport interface CurseforgePluginOptions extends CurseForgeOptions {\n /** CurseForge file references — numeric IDs or `/files/<id>` URLs. */\n files: CurseForgeFileRef[];\n}\n\n/** Mod files resolved from the CurseForge API. */\nexport function curseforge(options: CurseforgePluginOptions): OpysPlugin {\n return definePlugin({\n name: 'curseforge',\n async build(ctx) {\n const { files, ...rest } = options;\n const artifacts = await resolveCurseforge(rest, files);\n ctx.log('curseforge', `${artifacts.length} file(s)`);\n return { artifacts };\n },\n });\n}\n"],"mappings":";;;;AAGA,MAAM,iBAAiB;AACvB,MAAM,mBAAmB;;AAmDzB,SAAS,YAAY,QAAgB,UAA0B;AAG7D,QAAO,mCAFM,KAAK,MAAM,SAAS,IAAK,CAES,GADlC,SAAS,IACiC,GAAG,mBAAmB,SAAS;;AAGxF,SAAS,SAAS,MAAkC;AAClD,QAAO,KAAK,OAAO,MAAM,MAAM,EAAE,SAAS,EAAE,EAAE;;;;;;;AAQhD,SAAS,aAAa,KAAgC;AACpD,KAAI,OAAO,QAAQ,SAAU,QAAO;CACpC,MAAM,QAAQ,IAAI,MAAM,iBAAiB;AACzC,KAAI,CAAC,MACH,OAAM,IAAI,MACR,wBAAwB,IAAI,oFAC7B;AAEH,QAAO,OAAO,MAAM,GAAG;;AAGzB,eAAe,WAAW,OAAe,SAAsC;CAC7E,MAAM,MAAgB,EAAE;AACxB,MAAK,IAAI,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK,kBAAkB;EACzD,MAAM,QAAQ,QAAQ,MAAM,GAAG,IAAI,iBAAiB;EACpD,MAAM,MAAM,MAAM,eAAe,GAAG,eAAe,cAAc;GAC/D,QAAQ;GACR,SAAS;IACP,aAAa;IACb,QAAQ;IACR,gBAAgB;IACjB;GACD,MAAM,KAAK,UAAU,EAAE,SAAS,OAAO,CAAC;GACzC,CAAC;AACF,MAAI,CAAC,IAAI,GACP,OAAM,IAAI,MACR,kBAAkB,IAAI,OAAO,IAAI,IAAI,WAAW,qBACjD;EAEH,MAAM,OAAQ,MAAM,IAAI,MAAM;AAC9B,MAAI,KAAK,GAAG,KAAK,KAAK;;AAExB,QAAO;;;;;;;;;;;;;;;;;;;;;;AAuBT,eAAsB,kBACpB,SACA,OACqB;CACrB,MAAM,MAAM,MAAM,IAAI,aAAa;CACnC,MAAM,OAAO,MAAM,WAAW,QAAQ,OAAO,IAAI;CACjD,MAAM,OAAO,IAAI,IAAI,KAAK,KAAK,MAAM,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC;CAEhD,MAAM,YAAwB,EAAE;AAChC,MAAK,MAAM,UAAU,KAAK;EACxB,MAAM,OAAO,KAAK,IAAI,OAAO;AAC7B,MAAI,CAAC,KACH,OAAM,IAAI,MACR,mDAAmD,SACpD;EAGH,MAAM,OAAO,QAAQ,KAAK;GACxB,UAAU,KAAK;GACf,QAAQ,KAAK;GACb,WAAW,KAAK;GAChB,MAAM,KAAK;GACZ,CAAC;EAEF,MAAM,MAAM,KAAK,eAAe,YAAY,KAAK,IAAI,KAAK,SAAS;EACnE,MAAM,OAAO,SAAS,KAAK;EAC3B,MAAM,YAAmC,OAAO,EAAE,MAAM,GAAG;AAE3D,YAAU,KAAK;GACb;GACA,QAAQ,UAAU,IAAI;GACtB,MAAM,KAAK;GACX,OAAO,EAAE;GACT;GACD,CAAC;;AAGJ,QAAO;;;;;;ACpJT,SAAgB,WAAW,SAA8C;AACvE,QAAO,aAAa;EAClB,MAAM;EACN,MAAM,MAAM,KAAK;GACf,MAAM,EAAE,OAAO,GAAG,SAAS;GAC3B,MAAM,YAAY,MAAM,kBAAkB,MAAM,MAAM;AACtD,OAAI,IAAI,cAAc,GAAG,UAAU,OAAO,UAAU;AACpD,UAAO,EAAE,WAAW;;EAEvB,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opys/curseforge",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./dist/index.mjs",
|
|
9
|
+
"require": "./dist/index.cjs"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsdown lib/index.ts --format esm,cjs --dts --clean",
|
|
14
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
15
|
+
"test": "vitest run tests/unit --passWithNoTests"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@opys/core": "^0.1.2",
|
|
19
|
+
"@opys/dev": "^0.1.2"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"tsdown": "*",
|
|
23
|
+
"vitest": "*"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/harmoniya-net/opys.git",
|
|
34
|
+
"directory": "packages/curseforge"
|
|
35
|
+
}
|
|
36
|
+
}
|