diff --git a/package-lock.json b/package-lock.json index 20876ee..ddb9200 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2476,6 +2476,16 @@ "integrity": "sha512-V3orv+ggDsWVHP99K3JlwtH20R7J4IhI1Kksgc+64q5VxgfRkQG8Ws3MFm/FZOKDYGy9feGFlZ70/HpCNe9QaA==", "dev": true }, + "@types/node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-oMqjURCaxoSIsHSr1E47QHzbmzNR5rK8McHuNb11BOM9cHcIK3Avy0s/b2JlXHoQGTYS3NsvWzV1M0iK7l0wbA==", + "dev": true, + "requires": { + "@types/node": "*", + "form-data": "^3.0.0" + } + }, "@types/prettier": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.6.3.tgz", @@ -2855,6 +2865,11 @@ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "babel-jest": { "version": "28.1.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-28.1.0.tgz", @@ -3170,6 +3185,14 @@ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", "dev": true }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3251,6 +3274,11 @@ "object-keys": "^1.1.1" } }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, "deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", @@ -3697,13 +3725,14 @@ "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==", "dev": true }, - "formdata-node": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.3.2.tgz", - "integrity": "sha512-k7lYJyzDOSL6h917favP8j1L0/wNyylzU+x+1w4p5haGVHNlP58dbpdJhiCUsDbWsa9HwEtLp89obQgXl2e0qg==", + "form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", "requires": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.1" + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" } }, "fs.realpath": { @@ -5578,6 +5607,19 @@ "picomatch": "^2.3.1" } }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, "mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -5605,11 +5647,6 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, - "node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==" - }, "node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -6365,11 +6402,6 @@ "makeerror": "1.0.12" } }, - "web-streams-polyfill": { - "version": "4.0.0-beta.1", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.1.tgz", - "integrity": "sha512-3ux37gEX670UUphBF9AMCq8XM6iQ8Ac6A+DSRRjDoRBm1ufCkaCDdNVbaqq60PsEkdNlLKrGtv/YBP4EJXqNtQ==" - }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index fe9bc44..5484b27 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@babel/preset-env": "^7.18.2", "@babel/preset-typescript": "^7.17.12", "@types/node": "^17.0.36", + "@types/node-fetch": "^2.6.1", "@types/yazl": "^2.4.2", "@typescript-eslint/eslint-plugin": "^5.27.0", "@typescript-eslint/parser": "^5.27.0", @@ -44,7 +45,7 @@ "@actions/core": "^1.8.2", "@actions/github": "^5.0.3", "fast-glob": "^3.2.11", - "formdata-node": "^4.3.2", + "form-data": "^3.0.1", "node-fetch": "^2.6.7", "node-stream-zip": "^1.15.0", "toml": "^3.0.0" diff --git a/src/publishing/modrinth/modrinth-publisher.ts b/src/publishing/modrinth/modrinth-publisher.ts index 7014020..b570890 100644 --- a/src/publishing/modrinth/modrinth-publisher.ts +++ b/src/publishing/modrinth/modrinth-publisher.ts @@ -1,22 +1,40 @@ -import { createVersion } from "../../utils/modrinth-utils"; +import { createVersion, getProject } from "../../utils/modrinth-utils"; import { File } from "../../utils/file"; import ModPublisher from "../mod-publisher"; import PublisherTarget from "../publisher-target"; +import Dependency from "../../metadata/dependency"; +import DependencyKind from "../../metadata/dependency-kind"; + +const modrinthDependencyKinds = new Map([ + [DependencyKind.Depends, "required"], + [DependencyKind.Recommends, "optional"], + [DependencyKind.Suggests, "optional"], + [DependencyKind.Includes, "optional"], + [DependencyKind.Breaks, "incompatible"], +]); export default class ModrinthPublisher extends ModPublisher { public get target(): PublisherTarget { return PublisherTarget.Modrinth; } - protected async publishMod(id: string, token: string, name: string, version: string, channel: string, loaders: string[], gameVersions: string[], _java: string[], changelog: string, files: File[]): Promise { + protected async publishMod(id: string, token: string, name: string, version: string, channel: string, loaders: string[], gameVersions: string[], _java: string[], changelog: string, files: File[], dependencies: Dependency[]): Promise { + const projects = (await Promise.all(dependencies + .filter((x, _, self) => (x.kind !== DependencyKind.Suggests && x.kind !== DependencyKind.Includes) || !self.find(y => y.id === x.id && y.kind !== DependencyKind.Suggests && y.kind !== DependencyKind.Includes)) + .map(async x => ({ + project_id: (await getProject(x.getProjectSlug(this.target))).id, + dependency_type: modrinthDependencyKinds.get(x.kind) + })))) + .filter(x => x.project_id && x.dependency_type); + const data = { - version_title: name || version, + name: name || version, version_number: version, - version_body: changelog, - release_channel: channel, + changelog, game_versions: gameVersions, + version_type: channel, loaders, - featured: true, + dependencies: projects }; await createVersion(id, data, files, token); } diff --git a/src/utils/curseforge-utils.ts b/src/utils/curseforge-utils.ts index 2595f4d..cfeb1cb 100644 --- a/src/utils/curseforge-utils.ts +++ b/src/utils/curseforge-utils.ts @@ -1,6 +1,5 @@ import fetch from "node-fetch"; -import { FormData } from "formdata-node"; -import { fileFromPath } from "formdata-node/file-from-path"; +import FormData from "form-data"; import { File } from "./file"; import { findVersionByName } from "./minecraft-utils"; import SoftError from "./soft-error"; @@ -111,11 +110,12 @@ export async function uploadFile(id: string, data: Record, file: Fi } const form = new FormData(); - form.append("file", await fileFromPath(file.path), file.name); + form.append("file", file.getStream(), file.name); form.append("metadata", JSON.stringify(data)); const response = await fetch(`${baseUrl}/projects/${id}/upload-file?token=${token}`, { method: "POST", + headers: form.getHeaders(), body: form }); diff --git a/src/utils/file.ts b/src/utils/file.ts index 19b712f..ee936f4 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -11,6 +11,10 @@ export class File { Object.freeze(this); } + public getStream(): fs.ReadStream { + return fs.createReadStream(this.path); + } + public async getBuffer(): Promise { return new Promise((resolve, reject) => { fs.readFile(this.path, (error, data) => { diff --git a/src/utils/hash-utils.ts b/src/utils/hash-utils.ts deleted file mode 100644 index 72a6b6f..0000000 --- a/src/utils/hash-utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -import crypto from "crypto"; -import fs from "fs"; - -export function computeHash(path: string, algorithm: string): Promise { - const hash = crypto.createHash(algorithm); - return new Promise(resolve => fs.createReadStream(path).on("data", data => hash.update(data)).on("end", () => resolve(hash))); -} diff --git a/src/utils/modrinth-utils.ts b/src/utils/modrinth-utils.ts index 8bc881b..fa53452 100644 --- a/src/utils/modrinth-utils.ts +++ b/src/utils/modrinth-utils.ts @@ -1,15 +1,26 @@ -import { FormData } from "formdata-node"; -import { fileFromPath } from "formdata-node/file-from-path"; +import FormData from "form-data"; import fetch from "node-fetch"; import { File } from "./file"; -import { computeHash } from "./hash-utils"; import SoftError from "./soft-error"; -export async function createVersion(modId: string, data: Record, files: File[], token: string): Promise { +const baseUrl = "https://api.modrinth.com/v2"; + +interface ModrinthProject { + id: string; + slug: string; +} + +interface ModrinthVersion { + id: string; +} + +export async function createVersion(modId: string, data: Record, files: File[], token: string): Promise { data = { + featured: true, dependencies: [], ...data, - mod_id: modId, + project_id: modId, + primary_file: files.length ? "0" : undefined, file_parts: files.map((_, i) => i.toString()) }; @@ -17,12 +28,14 @@ export async function createVersion(modId: string, data: Record, fi form.append("data", JSON.stringify(data)); for (let i = 0; i < files.length; ++i) { const file = files[i]; - form.append(i.toString(), await fileFromPath(file.path), file.name); + form.append(i.toString(), file.getStream(), file.name); } - const response = await fetch("https://api.modrinth.com/api/v1/version", { + const response = await fetch(`${baseUrl}/version`, { method: "POST", - headers: { Authorization: token }, + headers: form.getHeaders({ + Authorization: token, + }), body: form }); @@ -35,27 +48,19 @@ export async function createVersion(modId: string, data: Record, fi throw new SoftError(isServerError, `Failed to upload file: ${response.status} (${errorText})`); } - const versionId = (<{ id: string }>await response.json()).id; - const primaryFile = files[0]; - if (primaryFile) { - await makeFilePrimary(versionId, primaryFile.path, token); + return await response.json(); +} + +export async function getProject(idOrSlug: string): Promise { + const response = await fetch(`${baseUrl}/project/${idOrSlug}`); + if (response.ok) { + return await response.json(); } - return versionId; -} -export async function makeFilePrimary(versionId: string, filePath: string, token: string): Promise { - const algorithm = "sha1"; - const hash = (await computeHash(filePath, algorithm)).digest("hex"); + if (response.status === 404) { + return null; + } - const response = await fetch(`https://api.modrinth.com/api/v1/version/${versionId}`, { - method: "PATCH", - headers: { - "Authorization": token, - "Content-Type": "application/json" - }, - body: JSON.stringify({ - primary_file: [algorithm, hash] - }) - }); - return response.ok; + const isServerError = response.status >= 500; + throw new SoftError(isServerError, `${response.status} (${response.statusText})`); } diff --git a/test/hast-utils.test.ts b/test/hast-utils.test.ts deleted file mode 100644 index 158f4ee..0000000 --- a/test/hast-utils.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, test, expect, beforeAll, afterAll } from "@jest/globals"; -import { computeHash } from "../src/utils/hash-utils"; -import fs from "fs"; - -describe("computeHash", () => { - beforeAll(() => new Promise(resolve => { - fs.writeFile("hello-world.txt", "Hello world!", resolve); - })); - - afterAll(() => new Promise(resolve => fs.unlink("hello-world.txt", resolve))); - - test("sha1 is supported", async () => { - const algorithm = "sha1"; - expect((await computeHash("hello-world.txt", algorithm)).digest("hex")).toBe("d3486ae9136e7856bc42212385ea797094475802"); - }); - - test("sha256 is supported", async () => { - const algorithm = "sha256"; - expect((await computeHash("hello-world.txt", algorithm)).digest("hex")).toBe("c0535e4be2b79ffd93291305436bf889314e4a3faec05ecffcbb7df31ad9e51a"); - }); - - test("sha512 is supported", async () => { - const algorithm = "sha512"; - expect((await computeHash("hello-world.txt", algorithm)).digest("hex")).toBe("f6cde2a0f819314cdde55fc227d8d7dae3d28cc556222a0a8ad66d91ccad4aad6094f517a2182360c9aacf6a3dc323162cb6fd8cdffedb0fe038f55e85ffb5b6"); - }); -}); diff --git a/test/modrinth-utils.test.ts b/test/modrinth-utils.test.ts new file mode 100644 index 0000000..7d3377f --- /dev/null +++ b/test/modrinth-utils.test.ts @@ -0,0 +1,37 @@ +import { jest, describe, test, expect } from "@jest/globals"; +import { getProject } from "../src/utils/modrinth-utils"; + +describe("getProject", () => { + test("returned versions have expected ids", async () => { + jest.setTimeout(15000); + const projects = { + "sodium": "AANobbMI", + "fabric-api": "P7dR8mSH", + "sync-fabric": "OrJTMhHF", + "nether-chest": "okOUGirG", + "ebe": "OVuFYfre", + }; + + for (const [slug, id] of Object.entries(projects)) { + const project = await getProject(slug); + expect(project).toHaveProperty("id", id); + } + }); + + test("the method returns null if project with the given slug does not exist", async () => { + jest.setTimeout(15000); + const nonExistentProjects = [ + "Na-11", + "api-fabric", + "sync-forge", + "ever-chest", + "beb", + "i-swear-to-god-if-someone-registers-these-mods" + ]; + + for (const slug of nonExistentProjects) { + const project = await getProject(slug); + expect(project).toBeNull(); + } + }); +});