ModReleaser/src/services/publish.ts

309 lines
8.5 KiB
TypeScript
Raw Normal View History

import { initTRPC } from "@trpc/server";
import { z } from "zod";
import EventEmitter, { on } from "node:events";
import { readFileSync } from "node:fs";
import path from "node:path";
import { getConfig } from "./config";
import { modrinthClient, curseforgeClient } from "./clients";
import { createVersion } from "@/lib/modrinth";
import { uploadFile } from "@/lib/curseforge";
import { Template } from "@/lib/utils/template";
import type { CreatableVersion, UploadMetadata } from "@/types";
// ── Zod schemas ────────────────────────────────────────
const PublishInputSchema = z.object({
configName: z.string(),
version: z.string(),
mcVersions: z.object({
modrinth: z.array(z.object({ mc_version: z.string() })),
curseforge: z.array(z.object({ mc_version: z.string() })),
}),
changelog: z.string(),
versionType: z.enum(["release", "beta", "alpha"]),
});
const ProgressEventSchema = z.object({
platform: z.enum(["modrinth", "curseforge"]),
current: z.number().int().min(0),
total: z.number().int().min(0),
status: z.enum(["running", "completed", "failed"]),
errors: z.array(z.string()),
});
const PlatformResultSchema = z.object({
success: z.number().int().min(0),
fail: z.number().int().min(0),
errors: z.array(z.string()),
});
const PublishResultSchema = z.object({
modrinth: PlatformResultSchema,
curseforge: PlatformResultSchema,
});
// ── Inferred types ─────────────────────────────────────
type PublishInput = z.infer<typeof PublishInputSchema>;
type ProgressEvent = z.infer<typeof ProgressEventSchema>;
type PlatformResult = z.infer<typeof PlatformResultSchema>;
type PublishResult = z.infer<typeof PublishResultSchema>;
// ── tRPC init ──────────────────────────────────────────
const t = initTRPC.create();
// ── Shared EventEmitter for progress ───────────────────
const ee = new EventEmitter();
// ── Helpers ────────────────────────────────────────────
function resolveVersionName(
template: string,
templates: Record<string, string> | undefined,
values: Record<string, string>,
): string {
const tmpl = new Template(template, templates);
return tmpl.format(values);
}
function buildJarPath(
projectDir: string,
format: string,
version: string,
mcVersion: string,
): string {
const tmpl = new Template(format);
const filename = tmpl.format({ version, mc_version: mcVersion });
return path.join(projectDir, "build", "libs", filename);
}
function readJarBlob(filePath: string): Blob {
try {
const buffer = readFileSync(filePath);
return new Blob([buffer]);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to read file "${filePath}": ${msg}`);
}
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
// ── Platform publish logic ─────────────────────────────
interface PlatformContext {
input: PublishInput;
config: Awaited<ReturnType<typeof getConfig>>;
}
async function publishModrinthVersion(
ctx: PlatformContext,
mcVersion: string,
): Promise<void> {
const { input, config } = ctx;
const versionNumber = resolveVersionName(
config.modrinth.version,
undefined,
{ version: input.version, mc_version: mcVersion },
);
const versionName = resolveVersionName(
config.modrinth.version_name,
undefined,
{
version: input.version,
mc_version: mcVersion,
mc_version_range: mcVersion,
},
);
const primaryPath = buildJarPath(
config.project_dir,
config.filename_format,
input.version,
mcVersion,
);
const sourcePath = buildJarPath(
config.project_dir,
config.source_filename_format,
input.version,
mcVersion,
);
const primaryBlob = readJarBlob(primaryPath);
const sourceBlob = readJarBlob(sourcePath);
const data: CreatableVersion = {
project_id: config.modrinth.project_id,
name: versionName,
version_number: versionNumber,
game_versions: [mcVersion],
version_type: input.versionType,
loaders: config.modrinth.loaders,
featured: false,
dependencies: config.modrinth.dependencies.map((d) => ({
version_id: null,
project_id: d.project_id,
file_name: null,
dependency_type: d.dependency_type,
})),
file_parts: ["primary", "sources"],
primary_file: "primary",
file_types: { sources: "sources-jar" },
environment: config.modrinth.environment,
};
await createVersion(modrinthClient, data, {
primary: primaryBlob,
sources: sourceBlob,
});
}
async function publishCurseForgeVersion(
ctx: PlatformContext,
mcVersion: string,
): Promise<void> {
const { input, config } = ctx;
const displayName = resolveVersionName(
config.curseforge.version_name,
{ filename_format: config.filename_format },
{ version: input.version, mc_version: mcVersion },
);
const primaryPath = buildJarPath(
config.project_dir,
config.filename_format,
input.version,
mcVersion,
);
const primaryBlob = readJarBlob(primaryPath);
const metadata: UploadMetadata = {
changelog: input.changelog,
changelogType: "markdown",
displayName,
releaseType: input.versionType,
relations: config.curseforge.relations,
};
await uploadFile(
curseforgeClient,
config.curseforge.project_id,
metadata,
primaryBlob,
);
}
async function runPlatform(
platform: "modrinth" | "curseforge",
mcVersions: { mc_version: string }[],
ctx: PlatformContext,
): Promise<PlatformResult> {
const result: PlatformResult = { success: 0, fail: 0, errors: [] };
const total = mcVersions.length;
if (total === 0) {
ee.emit("progress", {
platform,
current: 0,
total: 0,
status: "completed",
errors: [],
} satisfies ProgressEvent);
return result;
}
// Emit initial running state
ee.emit("progress", {
platform,
current: 0,
total,
status: "running",
errors: [],
} satisfies ProgressEvent);
const publishFn =
platform === "modrinth"
? publishModrinthVersion
: publishCurseForgeVersion;
for (let i = 0; i < mcVersions.length; i++) {
const { mc_version } = mcVersions[i];
try {
await publishFn(ctx, mc_version);
result.success++;
const isLast = i + 1 === total;
ee.emit("progress", {
platform,
current: i + 1,
total,
status: isLast ? "completed" : "running",
errors: result.errors,
} satisfies ProgressEvent);
} catch (err) {
result.fail++;
const msg = `${mc_version}: ${errorMessage(err)}`;
result.errors.push(msg);
ee.emit("progress", {
platform,
current: i,
total,
status: "failed",
errors: result.errors,
} satisfies ProgressEvent);
// Cancel remaining for this platform only
break;
}
}
return result;
}
// ── Router ─────────────────────────────────────────────
export const appRouter = t.router({
publish: t.procedure
.input(PublishInputSchema)
.mutation(async ({ input }): Promise<PublishResult> => {
const config = await getConfig(input.configName);
const ctx: PlatformContext = { input, config };
const [modrinthSettled, curseforgeSettled] = await Promise.allSettled([
runPlatform("modrinth", input.mcVersions.modrinth, ctx),
runPlatform("curseforge", input.mcVersions.curseforge, ctx),
]);
const modrinth: PlatformResult = modrinthSettled.status === "fulfilled"
? modrinthSettled.value
: { success: 0, fail: 0, errors: [errorMessage(modrinthSettled.reason)] };
const curseforge: PlatformResult = curseforgeSettled.status === "fulfilled"
? curseforgeSettled.value
: { success: 0, fail: 0, errors: [errorMessage(curseforgeSettled.reason)] };
return { modrinth, curseforge };
}),
progress: t.procedure.subscription(async function* (opts) {
for await (const [data] of on(ee, "progress", {
signal: opts.signal,
})) {
yield data as ProgressEvent;
}
}),
});
export type AppRouter = typeof appRouter;